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

tsx
<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

PropTypeDefaultNotes
labelstringRequired. Names the carousel region; without it the region is unlabelled.
slideWidthstring'18rem'Any CSS length or percentage. Slides are a fixed flex basis, so the track stays predictable.
gap2 | 3 | 4 | 54Spacing token step between slides.
loopbooleanfalseWraps around. Without it, the arrows disable at the ends.
align'start' | 'center''start'Where the active slide settles.
autoplaynumberMilliseconds between advances. Omit for a carousel that only moves when asked.
showArrows / showDotsbooleantrueDots only render when there is more than one snap point.

Keyboard and screen readers

KeyBehaviour
TabFocuses 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.

tsx
{loading ? <Carousel.Skeleton slides={4} slideWidth="16rem" /> : <Carousel>}