Getting started
Foundations
Integrations
Form19
Display5
Layout4
Navigation4
Overlay8
Feedback4
Motion19
Carousel
A carousel that snaps to where the flick was aimed, not to where it stopped.
#Installation
npx @dinachi/cli@latest add carousel#Usage
import {
Carousel,
CarouselViewport,
CarouselSlide,
CarouselDots,
CarouselPrevious,
CarouselNext,
} from "@/components/ui/carousel"<Carousel label="Highlights">
<CarouselViewport>
{slides.map((slide) => (
<CarouselSlide key={slide.id} label={slide.title}>
<Card slide={slide} />
</CarouselSlide>
))}
</CarouselViewport>
<div className="flex items-center justify-between">
<CarouselDots labels={slides.map((s) => s.title)} />
<div className="flex gap-1.5">
<CarouselPrevious />
<CarouselNext />
</div>
</div>
</Carousel>#Examples
#It snaps to where the flick was aimed
Snapping to the nearest slide measures where the finger stopped, which on a quick flick is barely past the slide it started on, so a decisive gesture bounces back and the carousel feels stuck.
This one projects the release velocity 200ms forward and snaps to whichever slide that lands in. A flick advances, a slow drag that stops short does not, and both match what the hand meant. The projection is measured from where the strip is, not from how far the finger travelled.
#Positions are measured, not calculated
The obvious shortcut is viewport.width * 0.72 + gap. It is wrong the moment the gap comes
from a class rather than a constant, or a slide is a different width, or the container has
padding. Asking the layout where the slides are costs one measurement per resize and cannot
drift.
Two things follow from that. A resize re-aligns the strip instead of leaving it sitting between two slides, and slides of uneven width land as accurately as even ones. The dots count themselves off what was measured, so they cannot get out of step with the strip they control.
#Reduced motion swaps the mechanism
The drag goes and the viewport becomes an ordinary scroll-snap strip. For anyone who did not want the momentum in the first place, a native scroller is the better carousel.
The dots and the arrows keep working either way. They just jump instead of animating.
#The dots are buttons
A tab controls a panel that appears in its place. These move a strip that is already fully
present, and calling them tabs promises a tabpanel relationship that does not exist. They
are buttons that jump, in a group named "Choose a slide".
#When to use it
| Use it for | Not for |
|---|---|
| A short, browsable set where seeing one at a time is the point: highlights, screenshots, testimonials. | Content the reader needs to compare. Anything that requires holding two slides in mind at once wants a grid. |
| Touch-first surfaces, where the gesture is the primary control. | Hiding content that matters. A carousel is a promise that what is off-screen is optional. |
#Accessibility
- The root is
role="group"witharia-roledescription="carousel"and yourlabel. - Each slide is a group with
aria-roledescription="slide"and a name that includes its position, as in2 of 4: Velocity handoff. Position is the part a screen reader user cannot get any other way. - The current dot carries
aria-current. - Both ends are real edges, not wrap points. The arrow that would run off the set is disabled. A carousel that silently wraps loses the reader's place in a set they were counting through.
#API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | "Carousel" | Names the carousel. Read before the slide |
| index | number | — | Current slide, controlled |
| defaultIndex | number | 0 | Starting slide, uncontrolled |
| onIndexChange | (index: number) => void | — | Fired when the carousel settles on a different slide |
| label | string | position | On CarouselSlide. Names the slide. Falls back to its position in the set |
| labels | string[] | — | On CarouselDots. Slide names, in order, used in each dot's accessible name |
| dotClassName | string | — | On CarouselDots. Class applied to every dot |