Carousel Composition
Built on Embla, with Motion for presentation. Hand-rolling this was the tempting option and the wrong one — drag on desktop, momentum on touch, loop, alignment, and resize are exactly the parts that get rewritten twice.
Usage
Drag, arrows, dots, or arrow keys
<Carousel label="Product areas" slideWidth="16rem"> {areas.map((area) => ( <Carousel.Slide key={area.id}> <Surface variant="tinted" hue={area.hue}>{area.title}</Surface> </Carousel.Slide> ))}</Carousel>Autoplay
Autoplay stops for four separate reasons, and every one of them is a bug when missed: the pointer is inside it, focus is inside it, the tab is in the background, or the person asked for reduced motion. That rule is a pure function with its own tests, not a condition buried in an effect.
Autoplay every 3s — hover or focus it and it stops
Do
Reserve autoplay for decorative content a person can afford to miss.
Don't
Autoplay anything a person must read or act on — it will move at the worst possible moment.
Props
| Prop | Type | Default | Notes |
|---|---|---|---|
| label | string | — | Required. Names the carousel region; without it the region is unlabelled. |
| slideWidth | string | '18rem' | Any CSS length or percentage. Slides are a fixed flex basis, so the track stays predictable. |
| gap | 2 | 3 | 4 | 5 | 4 | Spacing token step between slides. |
| loop | boolean | false | Wraps around. Without it, the arrows disable at the ends. |
| align | 'start' | 'center' | 'start' | Where the active slide settles. |
| autoplay | number | — | Milliseconds between advances. Omit for a carousel that only moves when asked. |
| showArrows / showDots | boolean | true | Dots only render when there is more than one snap point. |
Keyboard and screen readers
| Key | Behaviour |
|---|---|
Tab | Focuses the carousel region itself |
← / → | Previous and next slide |
The region carries aria-roledescription="carousel" and each slide is a group with
aria-roledescription="slide". Arrows are labelled, and the active dot carries aria-current.
Loading
Carousel.Skeleton renders the same track geometry — slide width, gap, count — so the row does not
resize when the real slides arrive.
{loading ? <Carousel.Skeleton slides={4} slideWidth="16rem" /> : <Carousel …>}