Carousel
Carousel
Items change size as they move through the container, and their captions adapt with them: the large item shows its title and year, the medium item drops the title, the small item shows neither. Scroll each row, or use the arrow keys, Home, and End.
Multi-browse
Hero
Center-aligned hero
Uncontained
Uncontained multi-aspect ratio








Full-screen
One edge-to-edge item that scrolls vertically, snapping to each in turn.
Showing item 1 of 10.
Show all
On a vertically scrolling page a carousel needs a route to every item that does not involve scrolling sideways. Material asks for a Show all button below it — a composition, not a prop.
Activate an item to see it reported here.
import { useMemo, useState, type ReactNode } from 'react'
import {
Button,
Carousel,
Surface,
Text,
type CarouselItem,
type CarouselLayout,
type MultiAspectCarouselItem,
} from '@language-lit/material3-expressive'
/**
* Local, package-bundled stock photos (Unsplash, via Picsum) rather than a
* hotlinked service: the example still needs no network request at render
* time, so the rendering audit measures the same pixels on every run.
*/
const albums = [
{ title: 'Coastal Static', year: '2019', photo: 'coastal-static' },
{ title: 'Long Exposure', year: '2020', photo: 'long-exposure' },
{ title: 'Harbour Lights', year: '2020', photo: 'harbour-lights' },
{ title: 'Night Ferry', year: '2021', photo: 'night-ferry' },
{ title: 'Second Summer', year: '2021', photo: 'second-summer' },
{ title: 'Low Tide', year: '2022', photo: 'low-tide' },
{ title: 'Signal Hill', year: '2022', photo: 'signal-hill' },
{ title: 'Winter Sessions', year: '2023', photo: 'winter-sessions' },
{ title: 'Open Water', year: '2023', photo: 'open-water' },
{ title: 'Last Train', year: '2024', photo: 'last-train' },
] as const
const ratios = [16 / 9, 1, 9 / 16, 4 / 3, 3 / 4, 16 / 9, 1, 9 / 16] as const
function photoSrc(photo: string) {
return `/images/carousel/${photo}.webp`
}
/**
* The specification's adaptive-content rule: the large item shows its title, the
* medium item hides it, and the small item abbreviates the label. Both spans are
* marked so the stylesheet withdraws them at the right widths. The photo is
* decorative: its subject conveys nothing the caption text does not already say.
*/
function AlbumContent({ title, year, photo }: { title: string; year: string; photo: string }) {
return (
<div className="carousel-example__cover">
<img className="carousel-example__photo" src={photoSrc(photo)} alt="" loading="lazy" />
<div className="carousel-example__caption">
<span data-m3e-carousel-hide="medium">{title}</span>
<span data-m3e-carousel-hide="small">{year}</span>
</div>
</div>
)
}
const albumItems = (onActivate: (title: string) => void): CarouselItem[] =>
albums.map((album) => ({
key: album.title,
label: album.title,
content: <AlbumContent {...album} />,
onActivate: () => onActivate(album.title),
}))
const aspectItems: MultiAspectCarouselItem[] = ratios.map((ratio, index) => ({
key: `ratio-${index}`,
label: `Clip ${index + 1}`,
aspectRatio: ratio,
content: (
<div className="carousel-example__cover">
<img
className="carousel-example__photo"
src={photoSrc(albums[index % albums.length]!.photo)}
alt=""
loading="lazy"
/>
</div>
),
}))
function Row({
id,
label,
description,
children,
}: {
id: CarouselLayout | 'showAll'
label: string
description?: string
children: ReactNode
}) {
return (
<div className="carousel-example__row">
<Text as="h3" variant="titleSmall" id={`carousel-example-${id}`}>
{label}
</Text>
{description === undefined ? null : (
<Text as="p" variant="bodySmall">
{description}
</Text>
)}
{children}
</div>
)
}
export function CarouselExample() {
const [opened, setOpened] = useState<string | null>(null)
const [current, setCurrent] = useState(0)
const items = useMemo(() => albumItems(setOpened), [])
return (
<Surface
as="section"
aria-labelledby="carousel-example-title"
color="surface-container-low"
shape="extra-large"
className="carousel-example"
>
<Text as="h2" id="carousel-example-title" variant="titleLarge" emphasis="emphasized">
Carousel
</Text>
<Text as="p" variant="bodyMedium">
Items change size as they move through the container, and their captions
adapt with them: the large item shows its title and year, the medium item
drops the title, the small item shows neither. Scroll each row, or use the
arrow keys, Home, and End.
</Text>
<Row id="multiBrowse" label="Multi-browse">
<Carousel
aria-labelledby="carousel-example-multiBrowse"
data-example-layout="multiBrowse"
preferredItemWidth={186}
items={items}
/>
</Row>
<Row id="hero" label="Hero">
<Carousel
aria-labelledby="carousel-example-hero"
data-example-layout="hero"
layout="hero"
maxItemWidth={320}
items={items}
/>
</Row>
<Row id="centeredHero" label="Center-aligned hero">
<Carousel
aria-labelledby="carousel-example-centeredHero"
data-example-layout="centeredHero"
layout="centeredHero"
items={items}
/>
</Row>
<Row id="uncontained" label="Uncontained">
<Carousel
aria-labelledby="carousel-example-uncontained"
data-example-layout="uncontained"
layout="uncontained"
itemWidth={220}
items={items}
/>
</Row>
<Row id="multiAspect" label="Uncontained multi-aspect ratio">
<Carousel
aria-labelledby="carousel-example-multiAspect"
data-example-layout="multiAspect"
layout="multiAspect"
items={aspectItems}
/>
</Row>
<Row
id="fullScreen"
label="Full-screen"
description="One edge-to-edge item that scrolls vertically, snapping to each in turn."
>
<Carousel
aria-labelledby="carousel-example-fullScreen"
data-example-layout="fullScreen"
className="carousel-example__full-screen"
layout="fullScreen"
items={items}
currentItem={current}
onCurrentItemChange={setCurrent}
/>
<Text as="p" variant="bodySmall">
Showing item {current + 1} of {items.length}.
</Text>
</Row>
<Row
id="showAll"
label="Show all"
description={
'On a vertically scrolling page a carousel needs a route to every item that ' +
'does not involve scrolling sideways. Material asks for a Show all button ' +
'below it \u2014 a composition, not a prop.'
}
>
<Carousel
aria-labelledby="carousel-example-showAll"
data-example-layout="showAll"
preferredItemWidth={186}
items={items}
/>
<Button variant="text" onClick={() => setOpened('all albums')}>
Show all
</Button>
</Row>
<Text as="p" variant="bodySmall" aria-live="polite">
{opened === null ? 'Activate an item to see it reported here.' : `Opened ${opened}.`}
</Text>
</Surface>
)
}Carousel shows a scrollable collection of mostly-visual items that change size
as they move through the container. One component covers all six layouts
Material specifies.
import { Button, Carousel } from '@language-lit/material3-expressive'
import '@language-lit/material3-expressive/styles.css'
// Multi-browse: many items at once, for quick browsing.
<Carousel
aria-label="Recent photos"
preferredItemWidth={186}
items={photos.map((photo) => ({
key: photo.id,
label: photo.title,
content: <img src={photo.src} alt="" />,
onActivate: () => open(photo),
}))}
/>
// Hero: one large item with a preview of what is next.
<Carousel aria-label="Featured" layout="centeredHero" items={featured} />
// Uncontained: same-size items that flow past the edge.
<Carousel aria-label="Articles" layout="uncontained" itemWidth={240} items={articles} />
// Multi-aspect ratio: each item keeps its own shape.
<Carousel
aria-label="Clips"
layout="multiAspect"
items={clips.map((clip) => ({ key: clip.id, content: <video src={clip.src} />, aspectRatio: clip.ratio }))}
/>Contract
items is the collection. Each item takes a key, its visual content, and
optionally a label, onActivate, href, and disabled. An item with
onActivate renders a real button and one with href a real a, so
Space/Enter activation and the browser's scroll-into-view on focus come from the
platform rather than from key handling.
layout selects the arrangement and defaults to "multiBrowse":
| Layout | Best for | Required prop |
|---|---|---|
multiBrowse | Browsing many visual items at once | preferredItemWidth |
uncontained | Text-heavy or highly customised items | itemWidth |
multiAspect | Items of genuinely different shapes | per-item aspectRatio |
hero | Spotlighting one very large item | — |
centeredHero | The same, centred between two previews | — |
fullScreen | Immersive vertical feeds | — |
preferredItemWidth is a target, not a guarantee: the arrangement adjusts small
items first, then medium ones, and only then the large width, so a whole number of
items fits the container. minSmallItemWidth and maxSmallItemWidth bound the
small item and default to the specified 40–56px range.
It is also the layout's only responsive lever, and it is yours to move. The
arrangement adds items as the container grows — at a fixed 186 it fits three
items at compact widths and eight at 1440px — but it does not grow the items
themselves. Material's guidelines put compact at window widths under 600dp, expect
up to three items there, and expect a growing window to both add items and scale
them up. Only the first of those falls out of the algorithm, so raise
preferredItemWidth at wider breakpoints if you want the second. The prop is a
plain number, so a window-size-class hook or a ResizeObserver is enough to drive
it. There is no specified ramp to copy: the first-party Compose sample hard-codes
186dp at every window size, which is why the examples here do the same.
One consequence is worth expecting rather than debugging. Whenever the arrangement fits more than one large item, those keylines share a size, so an item crosses them without resizing and only closes into its frame at the trailing edge. That is the arrangement working as specified — the browse region is stable and the resizing happens at the edges — and it is why a wide multi-browse carousel does not animate like a narrow one.
scroll chooses between the specification's two named behaviors and defaults to
the one it recommends for the layout: "snap" everywhere except the two
uncontained layouts, which default to "free". The full-screen layout requires
snapping, so leave it alone there.
currentItem, defaultCurrentItem, and onCurrentItemChange track the item at a
focal position. Setting currentItem scrolls to it; scrolling reports the nearest
item back. itemSpacing overrides the gap.
The carousel needs an accessible name, so pass aria-label or
aria-labelledby. Everything else on a div passes through, and the ref is the
scroll container.
Adaptive item content
Items report their current width, so content can adapt without re-rendering.
data-m3e-size is large, medium, or small on each item, and content marked
data-m3e-carousel-hide fades out as the item narrows past the width that
content needs:
<Carousel
aria-label="Albums"
preferredItemWidth={186}
items={albums.map((album) => ({
key: album.id,
label: album.title,
content: (
<>
<img src={album.cover} alt="" />
<figcaption>
<span data-m3e-carousel-hide="medium">{album.title}</span>
<span data-m3e-carousel-hide="small">{album.year}</span>
</figcaption>
</>
),
}))}
/>That is Material's own rule: the large item shows the full title, the medium item hides it, the small item abbreviates the label. It is a fade rather than a switch, because that is what the reference does — a title is pinned to the masking edge and faded out as the item becomes too small for it.
The attribute's value picks how long content survives, not a hard cutoff:
"medium" content is opaque on a focal item and gone by the middle of the range,
"small" content is opaque by that middle and gone at the narrowest visible item.
So a title leads its label out, and each transition is a fade you can watch rather
than a switch.
If you want text to stay put rather than be cropped as it fades, translate it by
--m3e-carousel-item-inset-start, as the reference pins its title. Prefer a
translation to padding: padding changes the content's own width, and content wide
enough to exceed the arrangement's item size is content the engine cannot place.
The exact pixel widths are
available too, as --m3e-carousel-item-current-size,
--m3e-carousel-item-min-size, and --m3e-carousel-item-max-size.
One caution if you use those numbers directly: --m3e-carousel-item-min-size
mirrors the Compose value, which counts the anchor keylines — the slivers
parked off screen at around 10px. It is not the narrowest item you can see, so
normalising a size against it will class almost everything as medium. data-m3e-size
already does the classification against the small keyline; prefer it.
Content is clipped to the item, never accommodated by it: an item is exactly the
arrangement's width, because the snap offsets are derived from that width. So text
that must stay readable as an item narrows should be padded by the mask insets,
--m3e-carousel-item-inset-start and --m3e-carousel-item-inset-end, which is
what they are exposed for — otherwise it is cropped mid-glyph rather than moved.
Show all — the accessibility requirement
On a vertically scrolling page, a horizontal carousel needs a route to every item that does not involve horizontal scrolling. Material asks for a Show all button below the carousel, or an arrow beside its header. That is a composition, not a prop:
<section aria-labelledby="recent-heading">
<h2 id="recent-heading">Recent</h2>
<Carousel aria-labelledby="recent-heading" preferredItemWidth={186} items={photos} />
<Button variant="text" onClick={() => router.push('/photos')}>
Show all
</Button>
</section>The requirement does not apply to the full-screen layout, which already scrolls the same axis as the page.
Behavior
The carousel is a real scroll container, so gestures, wheel, momentum, keyboard scrolling, and right-to-left come from the browser. Snapping is CSS scroll snap positioned at Material's own keylines, so a released gesture settles where the arrangement says it should. Fling distance is the browser's — Material's one-item-at-a-time fling has no web equivalent.
Item masking is computed from the scroll position, which is what makes items expand and collapse between the large, medium, and small widths and gives the image inside a mask its parallax.
Under prefers-reduced-motion: reduce the masking is withdrawn: every item stays
at its full size, nothing expands, and the items reach the container edges — the
reduced-motion presentation Material describes.
Tokens
Carousel is the one component whose tokens change the layout, not only the paint. Four of them feed the arrangement algorithm directly:
| Token | Default | Effect |
|---|---|---|
--m3e-comp-carousel-min-small-item-size | 40px | Narrowest a small item may be |
--m3e-comp-carousel-max-small-item-size | 56px | Widest a small item may be |
--m3e-comp-carousel-anchor-size | 10px | How far items travel past each edge |
--m3e-comp-carousel-medium-large-item-diff-threshold | 0.85 | When a medium item is too close to a large one |
The rest are ordinary appearance tokens: container-color, item-shape,
item-container-color, item-content-color, item-spacing, leading-padding,
trailing-padding, block-padding, uncontained-trailing-padding,
full-screen-padding, full-screen-item-spacing, the focus-ring trio, the
state-layer colour, and the two disabled opacities. Override any of them on the
theme or on one instance:
<Carousel
aria-label="Covers"
preferredItemWidth={186}
items={covers}
style={{ '--m3e-comp-carousel-item-shape': '12px' }}
/>Layout notes
Give the carousel a height. Items fill it, and the multi-aspect layout derives each item's width from that height and the item's ratio.
Item content is laid out at the unmasked item size and clipped, so an image should
fill its item — img, video, picture, svg, and canvas children are already
sized and cropped for you.