vert-ui
Browse docs
All components

Carousel

Scroll-snap slider built on embla-carousel. The root owns the state; buttons, dots and items read it through context, and a focusable viewport plus a polite status region keep it keyboard- and screen-reader-friendly.

Preview

Precision

Every token is measured, contrast-checked and documented.

Velocity

Copy-paste components that ship without a build step.

Restraint

Motion stays on transform and opacity, and honours reduced motion.

Slide 1 of 3

Usage

With dots and a callback

Precision

Velocity

Restraint

Slide 1 of 3
<Carousel
  className="w-full max-w-lg"
  onSlideChange={setIndex}
  footer={<CarouselDots className="mt-4" />}
>
  {items.map((item) => (
    <CarouselItem key={item.id}>…</CarouselItem>
  ))}
</Carousel>

Custom controls

Precision

Velocity

Restraint

Slide 1 of 3
<Carousel
  hideButtons
  footer={
    <div className="mt-4 flex items-center justify-center gap-2">
      <CarouselPrevious />
      <CarouselNext />
    </div>
  }
>
  {items.map((item) => (
    <CarouselItem key={item.id}>…</CarouselItem>
  ))}
</Carousel>

Custom parts with useCarousel

Precision

Velocity

Restraint

1 of 3
Slide 1 of 3
import { useCarousel } from "@/components/vert-ui/carousel"

function Counter() {
  const { index, count, scrollTo } = useCarousel()
  return (
    <div className="mt-4 flex items-center justify-center gap-4">
      <span>{index + 1} of {count}</span>
      <button type="button" onClick={() => scrollTo(0, true)}>Back to start</button>
    </div>
  )
}

<Carousel footer={<Counter />}>
  {items.map((item) => (
    <CarouselItem key={item.id}>…</CarouselItem>
  ))}
</Carousel>

Reference

PropTypeDefaultDescription
orientation"horizontal" | "vertical""horizontal"Slide direction; buttons reposition to the top edge when vertical.
loopbooleanfalseWraps from the last slide back to the first.
slidesToScrollnumber1How many slides each prev/next call advances.
defaultSlidenumber0Initial slide index (uncontrolled).
onSlideChange(index: number) => void—Fires with the selected slide index whenever it changes.
dragFreebooleanfalseDecouples scroll position from drag distance for a free-flowing feel.
hideButtonsbooleanfalseHides the built-in prev/next buttons — add your own labelled controls.
footerReactNode—Rendered below the viewport inside the carousel context — the right place for dots or custom controls, since direct children become slides.
CarouselPrevious / CarouselNextButtonHTMLAttributes—Default controls; disabled at each end and labelled "Previous slide" / "Next slide".
CarouselDotsHTMLAttributes—One button per slide — labelled "Go to slide n", with aria-current on the active dot.
useCarousel() => CarouselContextValue—Hook for custom parts (counters, progress, thumbnails): index, count, canPrev/canNext, scrollPrev/scrollNext/scrollTo and the embla API. Throws outside a Carousel.

Accessibility

  • The root is role="region" with aria-roledescription="carousel" — always pass an aria-label ("Principles" above) so it is a named landmark.
  • With more than one slide the viewport is focusable: Arrow keys move along the carousel's axis, Home/End jump to the first/last slide. Keys are ignored while focus sits in an input or editable inside a slide.
  • Slides carry role="group" with aria-roledescription="slide", so each one is identified while browsing.
  • Previous/Next are real <button> elements with aria-label="Previous slide" / "Next slide", visible focus rings and the disabled attribute at each end.
  • Dots are buttons with accessible names ("Go to slide n") and aria-current on the active one — a second way to move, never the only way.
  • A visually hidden status region (aria-live="polite") announces "Slide x of y" after every move, including keyboard and dot navigation.
  • Motion is transform-only (embla translates the container); under prefers-reduced-motion embla's animation duration drops to 0 and the global override kills CSS transitions.
  • Never auto-advance: a carousel that moves on its own breaks focus and reading position.