Free shadcn/ui carousel for React — built on Embla. Image and product carousels, multiple slides per view, vertical orientation, dot indicators, autoplay, and a synced thumbnail strip.

Installation

1 Configure registry — once per project
{
  "registries": {
    "@designrevision": "https://registry.designrevision.com/r/{name}.json"
  }
}

Add to your existing components.json. Requires shadcn/ui v2.3+.

2 Install the component

Packages

embla-carousel-react
utils
button

Props

orientation = "horizontal"
"horizontal" | "vertical"

Scroll axis of the carousel.

opts = —
EmblaOptionsType

Embla options — e.g. { loop: true, align: "start" }.

plugins = —
EmblaPluginType[]

Embla plugins, e.g. Autoplay() from embla-carousel-autoplay.

setApi = —
(api: CarouselApi) => void

Receive the Embla API to read/drive the carousel (dots, current slide).

A carousel built on Embla — compose <Carousel> with <CarouselContent> wrapping <CarouselItem>s, plus <CarouselPrevious> / <CarouselNext>. Each item is basis-full by default; set basis-1/2 or basis-1/3 on CarouselItem to show multiple per view. Pass opts (e.g. { loop: true }), orientation for a vertical carousel, plugins (Autoplay) for autoplay, and setApi to read the Embla API for dot indicators or the current slide. Arrow keys scroll it; slides carry the right ARIA roles.

Copied!
components/ui/carousel.tsx
"use client"

import * as React from "react"
import useEmblaCarousel, {
  type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"

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
}

type CarouselContextProps = {
  carouselRef: ReturnType<typeof useEmblaCarousel>[0]
  api: ReturnType<typeof useEmblaCarousel>[1]
  scrollPrev: () => void
  scrollNext: () => void
  canScrollPrev: boolean
  canScrollNext: boolean
} & CarouselProps

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

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,
  className,
  children,
  ...props
}: React.ComponentProps<"div"> & CarouselProps) {
  const [carouselRef, api] = useEmblaCarousel(
    {
      ...opts,
      axis: orientation === "horizontal" ? "x" : "y",
    },
    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())
  }, [])

  const scrollPrev = React.useCallback(() => {
    api?.scrollPrev()
  }, [api])

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

  const handleKeyDown = React.useCallback(
    (event: React.KeyboardEvent<HTMLDivElement>) => {
      if (event.key === "ArrowLeft") {
        event.preventDefault()
        scrollPrev()
      } else if (event.key === "ArrowRight") {
        event.preventDefault()
        scrollNext()
      }
    },
    [scrollPrev, scrollNext]
  )

  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,
      }}
    >
      <div
        onKeyDownCapture={handleKeyDown}
        className={cn("relative", className)}
        role="region"
        aria-roledescription="carousel"
        data-slot="carousel"
        {...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" ? "-ml-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" ? "pl-4" : "pt-4",
        className
      )}
      {...props}
    />
  )
}

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

  return (
    <Button
      data-slot="carousel-previous"
      variant={variant}
      size={size}
      className={cn(
        "absolute size-8 rounded-full",
        orientation === "horizontal"
          ? "top-1/2 -left-12 -translate-y-1/2"
          : "-top-12 left-1/2 -translate-x-1/2 rotate-90",
        className
      )}
      disabled={!canScrollPrev}
      onClick={scrollPrev}
      {...props}
    >
      <ArrowLeft />
      <span className="sr-only">Previous slide</span>
    </Button>
  )
}

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

  return (
    <Button
      data-slot="carousel-next"
      variant={variant}
      size={size}
      className={cn(
        "absolute size-8 rounded-full",
        orientation === "horizontal"
          ? "top-1/2 -right-12 -translate-y-1/2"
          : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
        className
      )}
      disabled={!canScrollNext}
      onClick={scrollNext}
      {...props}
    >
      <ArrowRight />
      <span className="sr-only">Next slide</span>
    </Button>
  )
}

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

Examples

Basic

The canonical carousel — slides with previous/next arrows, one per view.

Packages

carousel
card

Props

No props documented yet.

Copied!
components/ui/carousel-demo-01.tsx
import { Card, CardContent } from "@/components/ui/card"
import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from "@/components/ui/carousel"

export default function CarouselDemo01() {
  return (
    <Carousel className="w-full max-w-xs">
      <CarouselContent>
        {Array.from({ length: 5 }).map((_, i) => (
          <CarouselItem key={i}>
            <Card>
              <CardContent className="flex aspect-square items-center justify-center p-6">
                <span className="text-4xl font-semibold">{i + 1}</span>
              </CardContent>
            </Card>
          </CarouselItem>
        ))}
      </CarouselContent>
      <CarouselPrevious />
      <CarouselNext />
    </Carousel>
  )
}

Multiple per view

Show several slides at once — `basis-1/3` on CarouselItem with `align: "start"`.

Packages

carousel
card

Props

No props documented yet.

Copied!
components/ui/carousel-sizes-01.tsx
import { Card, CardContent } from "@/components/ui/card"
import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from "@/components/ui/carousel"

export default function CarouselSizes01() {
  return (
    <Carousel opts={{ align: "start" }} className="w-full max-w-sm">
      <CarouselContent>
        {Array.from({ length: 6 }).map((_, i) => (
          <CarouselItem key={i} className="basis-1/3">
            <Card>
              <CardContent className="flex aspect-square items-center justify-center p-6">
                <span className="text-2xl font-semibold">{i + 1}</span>
              </CardContent>
            </Card>
          </CarouselItem>
        ))}
      </CarouselContent>
      <CarouselPrevious />
      <CarouselNext />
    </Carousel>
  )
}

Vertical

A vertical carousel — `orientation="vertical"` with a fixed-height content area.

Packages

carousel
card

Props

No props documented yet.

Copied!
components/ui/carousel-vertical-01.tsx
import { Card, CardContent } from "@/components/ui/card"
import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from "@/components/ui/carousel"

export default function CarouselVertical01() {
  return (
    <Carousel
      orientation="vertical"
      opts={{ align: "start" }}
      className="w-full max-w-xs"
    >
      <CarouselContent className="h-[260px]">
        {Array.from({ length: 5 }).map((_, i) => (
          <CarouselItem key={i} className="basis-1/2">
            <Card>
              <CardContent className="flex items-center justify-center p-6">
                <span className="text-3xl font-semibold">{i + 1}</span>
              </CardContent>
            </Card>
          </CarouselItem>
        ))}
      </CarouselContent>
      <CarouselPrevious />
      <CarouselNext />
    </Carousel>
  )
}

Dot indicators

Dot navigation and the current slide, read from the Embla API via `setApi`.

Packages

carousel
card

Props

No props documented yet.

Copied!
components/ui/carousel-dots-01.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { Card, CardContent } from "@/components/ui/card"
import {
  Carousel,
  type CarouselApi,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from "@/components/ui/carousel"

export default function CarouselDots01() {
  const [api, setApi] = React.useState<CarouselApi>()
  const [current, setCurrent] = React.useState(0)
  const [count, setCount] = React.useState(0)

  React.useEffect(() => {
    if (!api) return
    setCount(api.scrollSnapList().length)
    setCurrent(api.selectedScrollSnap())
    api.on("select", () => setCurrent(api.selectedScrollSnap()))
  }, [api])

  return (
    <div className="w-full max-w-xs">
      <Carousel setApi={setApi}>
        <CarouselContent>
          {Array.from({ length: 5 }).map((_, i) => (
            <CarouselItem key={i}>
              <Card>
                <CardContent className="flex aspect-square items-center justify-center p-6">
                  <span className="text-4xl font-semibold">{i + 1}</span>
                </CardContent>
              </Card>
            </CarouselItem>
          ))}
        </CarouselContent>
        <CarouselPrevious />
        <CarouselNext />
      </Carousel>
      <div className="mt-4 flex items-center justify-center gap-2">
        {Array.from({ length: count }).map((_, i) => (
          <button
            key={i}
            onClick={() => api?.scrollTo(i)}
            aria-label={`Go to slide ${i + 1}`}
            className={cn(
              "size-2 rounded-full transition-colors",
              i === current ? "bg-primary" : "bg-primary/30"
            )}
          />
        ))}
      </div>
    </div>
  )
}

Autoplay

Auto-advancing slides with the `embla-carousel-autoplay` plugin, pausing on hover.

Packages

embla-carousel-autoplay
carousel
card

Props

No props documented yet.

Copied!
components/ui/carousel-autoplay-01.tsx
"use client"

import * as React from "react"
import Autoplay from "embla-carousel-autoplay"

import { Card, CardContent } from "@/components/ui/card"
import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from "@/components/ui/carousel"

export default function CarouselAutoplay01() {
  const plugin = React.useRef(
    Autoplay({ delay: 2000, stopOnInteraction: true })
  )

  return (
    <Carousel
      plugins={[plugin.current]}
      className="w-full max-w-xs"
      onMouseEnter={plugin.current.stop}
      onMouseLeave={plugin.current.reset}
    >
      <CarouselContent>
        {Array.from({ length: 5 }).map((_, i) => (
          <CarouselItem key={i}>
            <Card>
              <CardContent className="flex aspect-square items-center justify-center p-6">
                <span className="text-4xl font-semibold">{i + 1}</span>
              </CardContent>
            </Card>
          </CarouselItem>
        ))}
      </CarouselContent>
      <CarouselPrevious />
      <CarouselNext />
    </Carousel>
  )
}

Image carousel

A looping image carousel — aspect-video photos that slide with the arrows.

Packages

carousel

Props

No props documented yet.

Copied!
components/ui/carousel-images-01.tsx
import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from "@/components/ui/carousel"

const images = [10, 20, 30, 40, 50]

export default function CarouselImages01() {
  return (
    <Carousel opts={{ loop: true }} className="w-full max-w-md">
      <CarouselContent>
        {images.map((seed, i) => (
          <CarouselItem key={seed}>
            {/* eslint-disable-next-line @next/next/no-img-element */}
            <img
              src={`https://picsum.photos/seed/${seed}/640/360`}
              alt={`Slide ${i + 1}`}
              className="aspect-video w-full rounded-lg object-cover"
            />
          </CarouselItem>
        ))}
      </CarouselContent>
      <CarouselPrevious />
      <CarouselNext />
    </Carousel>
  )
}

Product carousel

Image cards with name and price, two per view — the e-commerce pattern.

Packages

carousel
card

Props

No props documented yet.

Copied!
components/ui/carousel-product-01.tsx
import { Card, CardContent } from "@/components/ui/card"
import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from "@/components/ui/carousel"

const products = [
  { id: 101, name: "Aergo Headphones", price: "$129" },
  { id: 102, name: "Lumen Desk Lamp", price: "$59" },
  { id: 103, name: "Terra Mug", price: "$24" },
  { id: 104, name: "Nimbus Backpack", price: "$89" },
  { id: 105, name: "Pulse Watch", price: "$199" },
]

export default function CarouselProduct01() {
  return (
    <Carousel opts={{ align: "start" }} className="w-full max-w-md">
      <CarouselContent>
        {products.map((product) => (
          <CarouselItem key={product.id} className="basis-1/2">
            <Card className="overflow-hidden p-0">
              <CardContent className="p-0">
                {/* eslint-disable-next-line @next/next/no-img-element */}
                <img
                  src={`https://picsum.photos/seed/${product.id}/400/400`}
                  alt={product.name}
                  className="aspect-square w-full object-cover"
                />
                <div className="space-y-1 p-3">
                  <h3 className="text-sm leading-none font-medium">
                    {product.name}
                  </h3>
                  <p className="text-muted-foreground text-sm">
                    {product.price}
                  </p>
                </div>
              </CardContent>
            </Card>
          </CarouselItem>
        ))}
      </CarouselContent>
      <CarouselPrevious />
      <CarouselNext />
    </Carousel>
  )
}

Thumbnails

A main image carousel synced to a thumbnail strip — two Embla instances driving each other.

Packages

carousel

Props

No props documented yet.

Copied!
components/ui/carousel-thumbnails-01.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  Carousel,
  type CarouselApi,
  CarouselContent,
  CarouselItem,
} from "@/components/ui/carousel"

const images = [11, 22, 33, 44, 55]

export default function CarouselThumbnails01() {
  const [mainApi, setMainApi] = React.useState<CarouselApi>()
  const [thumbApi, setThumbApi] = React.useState<CarouselApi>()
  const [current, setCurrent] = React.useState(0)

  const onThumbClick = React.useCallback(
    (index: number) => mainApi?.scrollTo(index),
    [mainApi]
  )

  React.useEffect(() => {
    if (!mainApi) return
    const onSelect = () => {
      const index = mainApi.selectedScrollSnap()
      setCurrent(index)
      thumbApi?.scrollTo(index)
    }
    onSelect()
    mainApi.on("select", onSelect)
    return () => {
      mainApi.off("select", onSelect)
    }
  }, [mainApi, thumbApi])

  return (
    <div className="w-full max-w-md space-y-3">
      <Carousel setApi={setMainApi} opts={{ loop: true }}>
        <CarouselContent>
          {images.map((seed, i) => (
            <CarouselItem key={seed}>
              {/* eslint-disable-next-line @next/next/no-img-element */}
              <img
                src={`https://picsum.photos/seed/${seed}/640/360`}
                alt={`Slide ${i + 1}`}
                className="aspect-video w-full rounded-lg object-cover"
              />
            </CarouselItem>
          ))}
        </CarouselContent>
      </Carousel>
      <Carousel
        setApi={setThumbApi}
        opts={{ containScroll: "keepSnaps", dragFree: true }}
      >
        <CarouselContent className="-ml-2">
          {images.map((seed, i) => (
            <CarouselItem key={seed} className="basis-1/5 pl-2">
              <button
                onClick={() => onThumbClick(i)}
                className={cn(
                  "overflow-hidden rounded-md border-2 transition-colors",
                  i === current ? "border-primary" : "border-transparent"
                )}
              >
                {/* eslint-disable-next-line @next/next/no-img-element */}
                <img
                  src={`https://picsum.photos/seed/${seed}/120/80`}
                  alt={`Thumbnail ${i + 1}`}
                  className="aspect-video w-full object-cover"
                />
              </button>
            </CarouselItem>
          ))}
        </CarouselContent>
      </Carousel>
    </div>
  )
}

Frequently asked questions

It is a carousel built on Embla (embla-carousel-react). You compose with wrapping slides, plus and arrows. Embla handles the dragging, snapping, and momentum; the shadcn layer adds the styling, the arrow buttons, keyboard support, and accessibility roles.
Embla (embla-carousel-react) is the lightweight, dependency-free carousel engine the component is built on — install it with npm install embla-carousel-react. Pass Embla options through the opts prop (e.g. { loop: true, align: "start" }) and Embla plugins through plugins (e.g. Autoplay).
Put an in each CarouselItem and let it fill the slide (e.g. aspect-video w-full object-cover). Add opts={{ loop: true }} so it wraps around. The Image carousel example is a ready-to-use looping photo slider; the Thumbnails example pairs it with a thumbnail strip.
Set a basis fraction on each CarouselItem: className="basis-1/3" shows three per view, basis-1/2 shows two. Use responsive variants (basis-1/2 md:basis-1/3) to change the count by breakpoint, and opts={{ align: "start" }} so they line up. The Multiple per view and Product examples show it.
Install embla-carousel-autoplay and pass it as a plugin: const plugin = React.useRef(Autoplay({ delay: 2000 })) then . Wire onMouseEnter={plugin.current.stop} and onMouseLeave={plugin.current.reset} to pause on hover. The Autoplay example does exactly this.
Pass setApi to get the Embla API, then read api.scrollSnapList().length for the count and api.selectedScrollSnap() for the current index, updating on the api "select" event. Render a dot per snap and call api.scrollTo(i) on click. The Dot indicators example is the full pattern.
Yes — set orientation="vertical". Give the CarouselContent a fixed height (e.g. h-[260px]) so the slides have room to stack, and the arrows move to the top and bottom automatically. The Vertical example shows it.