Skip to contentVibraUI
Data display

Carousel

Slides on Embla, with previous and next buttons and keyboard arrows.

shadcn's base-nova carousel on Embla, with four changes. The root is a region described as a carousel and named "Slides" until you name it — aria-label, or aria-labelledby at a visible heading — and region={false} hands the region to an ancestor that is one, a section or a card named by its title. The arrow keys follow the axis: Left and Right move a row, Up and Down a column. A row follows the reading direction too: inside a right-to-left page (the nearest dir, else the computed direction) Embla is handed direction "rtl" unless opts.direction says otherwise, Left moves on and Right goes back, the previous button hangs off the row's start — the right — and the next off its end, their chevrons turn to point the way they move, and the gutters sit on the start side (ms/ps rather than ml/pl), so a page flips without a class of its own. Embla moves the track from script, where the reduced-motion media query never reaches, so the buttons and the keys jump rather than slide for a reader who asked for less motion (the OS setting or a [data-motion="reduced"] above); call api.scrollTo(index, prefersReducedMotion(element)) for controls of your own. Each item is a group described as a slide — name it "2 of 5". A slide that takes the focus from Tab scrolls into view (Embla's watchFocus). The previous and next controls are Vibra's outline icon buttons; name them for what they move when a press moves more than one slide.

Install

npx shadcn@latest add @vibra/carousel

Needs the @vibra registry in your components.json — set it up once.

Examples

Several at a time

Three to a view, two on a tablet, one and a peek on a phone; a press moves a whole view along.

Dots and a count

Each dot a named button, the current one marked and drawn longer, and the count in a polite live line.

Vertical

A side panel's feed two items tall, with its arrows above and below and Up and Down on the keys.

With thumbnails

A strip of named thumbnail buttons under the pictures; the one on show is current and on the selected plane.

In a card

The card is the region, named by its title; the carousel passes region={false} and its controls sit in the strip.

Slides that are links

Saved reports as links; the one Tab reaches off to the side slides into view.

Looping quotes

Customers in their own words, looping so neither button greys out; the faces are decoration.

Turns on its own

Notices that rotate until stopped: still under reduced motion, held on hover and focus, and silent while they run.

A short tour

Steps moved only by Back and Next, the ones behind inert; Done says so and becomes the way back to the start.

Right to left

The demo in Arabic: the row starts at the right, back sits on the right and next on the left, and Left is the key that moves on.

Props

PropTypeDefaultDescription
regionbooleantrueThe root is the carousel region, named "Slides" until named. Pass false when an ancestor carries the region, its name and aria-roledescription.
orientation"horizontal" | "vertical""horizontal"The axis the slides move along.
optsEmblaOptionsType—Embla's options — loop, align, slidesToScroll.
pluginsEmblaPluginType[]—Embla plugins, such as autoplay.
setApi(api: CarouselApi) => void—Hands you Embla's API, for a counter or dots of your own.

Dependencies

Source

components/ui/carousel.tsx
"use client"

import * as React from "react"
import { cn } from "@/lib/utils"
import useEmblaCarousel, {
  type UseEmblaCarouselType,
} from "embla-carousel-react"

import { prefersReducedMotion } from "@/hooks/use-reduced-motion"
import { Button } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"

type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]

type CarouselProps = {
  opts?: CarouselOptions
  plugins?: CarouselPlugin
  orientation?: "horizontal" | "vertical"
  setApi?: (api: CarouselApi) => void
  /**
   * Whether the root is the carousel region. On by default: a region described
   * as a carousel and named "Slides" until you name it with aria-label or
   * aria-labelledby. Off when an ancestor is the region — a section or a card
   * named by its heading — so the page does not nest a second one inside it.
   */
  region?: boolean
}

type CarouselContextProps = {
  carouselRef: ReturnType<typeof useEmblaCarousel>[0]
  api: ReturnType<typeof useEmblaCarousel>[1]
  scrollPrev: () => void
  scrollNext: () => void
  canScrollPrev: boolean
  canScrollNext: boolean
  /** The reading direction the slides run in: the page's, unless opts.direction says otherwise. */
  direction: "ltr" | "rtl"
} & CarouselProps

const CarouselContext = React.createContext<CarouselContextProps | null>(null)

/** The reading direction an element sits in: the nearest dir attribute, else its computed direction. */
function directionOf(element: HTMLElement): "ltr" | "rtl" {
  const attribute = element.closest("[dir]")?.getAttribute("dir")?.toLowerCase()
  if (attribute === "rtl" || attribute === "ltr") return attribute
  return getComputedStyle(element).direction === "rtl" ? "rtl" : "ltr"
}

function useCarousel() {
  const context = React.useContext(CarouselContext)

  if (!context) {
    throw new Error("useCarousel must be used within a <Carousel />")
  }

  return context
}

function Carousel({
  orientation = "horizontal",
  opts,
  setApi,
  plugins,
  region = true,
  className,
  children,
  ...props
}: React.ComponentProps<"div"> & CarouselProps) {
  const rootRef = React.useRef<HTMLDivElement>(null)
  // The reading direction the carousel sits in, read off the page once it is
  // mounted: Embla has to be told, or a right-to-left row starts at the wrong
  // end and runs backwards. opts.direction, when given, wins.
  const [pageDirection, setPageDirection] = React.useState<"ltr" | "rtl">("ltr")
  React.useLayoutEffect(() => {
    if (rootRef.current) setPageDirection(directionOf(rootRef.current))
  }, [])
  const direction = opts?.direction ?? pageDirection
  const [carouselRef, api] = useEmblaCarousel(
    {
      ...opts,
      axis: orientation === "horizontal" ? "x" : "y",
      direction,
    },
    plugins
  )
  const [canScrollPrev, setCanScrollPrev] = React.useState(false)
  const [canScrollNext, setCanScrollNext] = React.useState(false)

  const onSelect = React.useCallback((api: CarouselApi) => {
    if (!api) return
    setCanScrollPrev(api.canScrollPrev())
    setCanScrollNext(api.canScrollNext())
  }, [])

  // Embla moves the track from script, where the reduced-motion media query
  // never reaches, so a reader who asked for less motion gets the jump rather
  // than the slide — the OS setting or a [data-motion="reduced"] above.
  const scrollPrev = React.useCallback(() => {
    api?.scrollPrev(prefersReducedMotion(rootRef.current))
  }, [api])

  const scrollNext = React.useCallback(() => {
    api?.scrollNext(prefersReducedMotion(rootRef.current))
  }, [api])

  // The keys follow the axis the slides move along, and a row follows the
  // reading direction: in a right-to-left one the next slide is to the left.
  const [backKey, forwardKey] =
    orientation === "vertical"
      ? ["ArrowUp", "ArrowDown"]
      : direction === "rtl"
        ? ["ArrowRight", "ArrowLeft"]
        : ["ArrowLeft", "ArrowRight"]
  const handleKeyDown = React.useCallback(
    (event: React.KeyboardEvent<HTMLDivElement>) => {
      if (event.key === backKey) {
        event.preventDefault()
        scrollPrev()
      } else if (event.key === forwardKey) {
        event.preventDefault()
        scrollNext()
      }
    },
    [backKey, forwardKey, scrollPrev, scrollNext]
  )

  const named = props["aria-label"] !== undefined || props["aria-labelledby"] !== undefined
  const regionProps = region
    ? { role: "region", "aria-roledescription": "carousel", ...(named ? null : { "aria-label": "Slides" }) }
    : null

  React.useEffect(() => {
    if (!api || !setApi) return
    setApi(api)
  }, [api, setApi])

  React.useEffect(() => {
    if (!api) return
    onSelect(api)
    api.on("reInit", onSelect)
    api.on("select", onSelect)

    return () => {
      api?.off("select", onSelect)
    }
  }, [api, onSelect])

  return (
    <CarouselContext.Provider
      value={{
        carouselRef,
        api: api,
        opts,
        orientation:
          orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
        scrollPrev,
        scrollNext,
        canScrollPrev,
        canScrollNext,
        direction,
      }}
    >
      <div
        ref={rootRef}
        onKeyDownCapture={handleKeyDown}
        className={cn("relative", className)}
        {...regionProps}
        data-slot="carousel"
        data-orientation={orientation}
        {...props}
      >
        {children}
      </div>
    </CarouselContext.Provider>
  )
}

function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
  const { carouselRef, orientation } = useCarousel()

  return (
    <div
      ref={carouselRef}
      className="overflow-hidden"
      data-slot="carousel-content"
    >
      <div
        className={cn(
          "flex",
          orientation === "horizontal" ? "-ms-4" : "-mt-4 flex-col",
          className
        )}
        {...props}
      />
    </div>
  )
}

function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
  const { orientation } = useCarousel()

  return (
    <div
      role="group"
      aria-roledescription="slide"
      data-slot="carousel-item"
      className={cn(
        "min-w-0 shrink-0 grow-0 basis-full",
        orientation === "horizontal" ? "ps-4" : "pt-4",
        className
      )}
      {...props}
    />
  )
}

function CarouselPrevious({
  className,
  variant = "outline",
  size = "icon-sm",
  ...props
}: React.ComponentProps<typeof Button>) {
  const { orientation, scrollPrev, canScrollPrev } = useCarousel()

  return (
    <Button
      data-slot="carousel-previous"
      variant={variant}
      size={size}
      className={cn(
        "absolute touch-manipulation rounded-full",
        orientation === "horizontal"
          ? "inset-y-0 -start-12 my-auto"
          : "-top-12 left-1/2 -translate-x-1/2 rotate-90",
        className
      )}
      disabled={!canScrollPrev}
      onClick={scrollPrev}
      {...props}
    >
      {/* Back points the way the row came from: left, or right in a right-to-left row. */}
      <ChevronLeftIcon className={cn(orientation === "horizontal" && "rtl:rotate-180")} />
      <span className="sr-only">Previous slide</span>
    </Button>
  )
}

function CarouselNext({
  className,
  variant = "outline",
  size = "icon-sm",
  ...props
}: React.ComponentProps<typeof Button>) {
  const { orientation, scrollNext, canScrollNext } = useCarousel()

  return (
    <Button
      data-slot="carousel-next"
      variant={variant}
      size={size}
      className={cn(
        "absolute touch-manipulation rounded-full",
        orientation === "horizontal"
          ? "inset-y-0 -end-12 my-auto"
          : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
        className
      )}
      disabled={!canScrollNext}
      onClick={scrollNext}
      {...props}
    >
      <ChevronRightIcon className={cn(orientation === "horizontal" && "rtl:rotate-180")} />
      <span className="sr-only">Next slide</span>
    </Button>
  )
}

export {
  type CarouselApi,
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselPrevious,
  CarouselNext,
  useCarousel,
}