Carousel

Animated

A carousel that snaps to where the flick was aimed, not to where it stopped.

View Source

#Installation

npx @dinachi/cli@latest add carousel

#Usage

tsx
import {
  Carousel,
  CarouselViewport,
  CarouselSlide,
  CarouselDots,
  CarouselPrevious,
  CarouselNext,
} from "@/components/ui/carousel"
tsx
<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 forNot 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" with aria-roledescription="carousel" and your label.
  • Each slide is a group with aria-roledescription="slide" and a name that includes its position, as in 2 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

PropTypeDefaultDescription
labelstring"Carousel"Names the carousel. Read before the slide
indexnumberCurrent slide, controlled
defaultIndexnumber0Starting slide, uncontrolled
onIndexChange(index: number) => voidFired when the carousel settles on a different slide
labelstringpositionOn CarouselSlide. Names the slide. Falls back to its position in the set
labelsstring[]On CarouselDots. Slide names, in order, used in each dot's accessible name
dotClassNamestringOn CarouselDots. Class applied to every dot