Skip to content

Carousel

A horizontally or vertically scrolling set of slides with swipe, keyboard and button navigation, built on Embla.

SourceEmblaRegistry
01
TokensOne source of truth
02
ComponentsAccessible by default
03
PatternsProven compositions
04
ThemesBring your own brand
import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from "@/components/ui/carousel"

const slides = [
  {
    title: "Tokens",
    body: "One source of truth",
  },
  {
    title: "Components",
    body: "Accessible by default",
  },
  {
    title: "Patterns",
    body: "Proven compositions",
  },
  {
    title: "Themes",
    body: "Bring your own brand",
  },
]

export default function CarouselDemo() {
  return (
    <Carousel className="w-full max-w-44" aria-label="prfct highlights">
      <CarouselContent>
        {slides.map((slide, index) => (
          <CarouselItem
            key={slide.title}
            aria-label={`${index + 1} of ${slides.length}`}
          >
            <div className="flex aspect-square flex-col justify-between rounded-xl bg-muted p-4">
              <span className="font-mono text-xs text-muted-foreground numeric">
                {String(index + 1).padStart(2, "0")}
              </span>
              <div className="flex flex-col">
                <span className="text-heading-md text-foreground">
                  {slide.title}
                </span>
                <span className="text-sm text-muted-foreground">
                  {slide.body}
                </span>
              </div>
            </div>
          </CarouselItem>
        ))}
      </CarouselContent>
      <CarouselPrevious />
      <CarouselNext />
    </Carousel>
  )
}

Anatomy

1
2
3
  1. 1ContentThe viewport. It clips the slides that are off screen and follows a drag or a swipe.
  2. 2PreviousMoves one slide back. Disabled on the first slide unless the carousel loops.
  3. 3ItemOne slide, a group announced as a slide. Label it by its position.
  4. 4NextMoves one slide forward. Disabled on the last slide unless the carousel loops.

Installation

$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/carousel.json

Usage

import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from "@/components/ui/carousel"
<Carousel aria-label="Product screenshots">
  <CarouselContent>
    <CarouselItem>…</CarouselItem>
    <CarouselItem>…</CarouselItem>
    <CarouselItem>…</CarouselItem>
  </CarouselContent>
  <CarouselPrevious />
  <CarouselNext />
</Carousel>

The previous and next buttons sit outside the slides, 48px to the left and right. Leave room for them — or position them yourself with className.

Examples

Multiple per view

Set the slide width with a basis-* class on CarouselItem. Combine it with responsive prefixes to show more slides on wider screens, and opts={{ align: "start" }} to snap slides to the leading edge.

InvoiceLine items, tax and totals
ChangelogDated entries with tags
Pricing pageThree tiers and an FAQ
Onboarding emailA five-step welcome series
Status pageUptime and incident history
Release notesHighlights, fixes, upgrades
Team wikiNested pages with search
import {
  ActivityIcon,
  BookOpenIcon,
  MailIcon,
  ReceiptIcon,
  RocketIcon,
  ScrollTextIcon,
  TagIcon,
  type LucideIcon,
} from "lucide-react"

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

const templates: { name: string; description: string; icon: LucideIcon }[] = [
  {
    name: "Invoice",
    description: "Line items, tax and totals",
    icon: ReceiptIcon,
  },
  {
    name: "Changelog",
    description: "Dated entries with tags",
    icon: ScrollTextIcon,
  },
  {
    name: "Pricing page",
    description: "Three tiers and an FAQ",
    icon: TagIcon,
  },
  {
    name: "Onboarding email",
    description: "A five-step welcome series",
    icon: MailIcon,
  },
  {
    name: "Status page",
    description: "Uptime and incident history",
    icon: ActivityIcon,
  },
  {
    name: "Release notes",
    description: "Highlights, fixes, upgrades",
    icon: RocketIcon,
  },
  {
    name: "Team wiki",
    description: "Nested pages with search",
    icon: BookOpenIcon,
  },
]

export default function CarouselSizes() {
  return (
    // px-12 leaves room for the previous and next buttons, which sit outside the slides.
    <div className="w-full max-w-lg px-12">
      <Carousel opts={{ align: "start" }} aria-label="Templates">
        <CarouselContent>
          {templates.map((template, index) => (
            <CarouselItem
              key={template.name}
              className="basis-1/2 sm:basis-1/3"
              aria-label={`${index + 1} of ${templates.length}`}
            >
              <Card size="sm" className="h-full">
                <CardContent className="flex h-full flex-col gap-6">
                  <div
                    className="flex size-9 items-center justify-center rounded-lg border bg-surface text-muted-foreground"
                    aria-hidden="true"
                  >
                    <template.icon className="size-4" />
                  </div>
                  <div className="mt-auto flex flex-col gap-0.5">
                    <span className="text-sm font-medium">{template.name}</span>
                    <span className="text-xs text-muted-foreground">
                      {template.description}
                    </span>
                  </div>
                </CardContent>
              </Card>
            </CarouselItem>
          ))}
        </CarouselContent>
        <CarouselPrevious />
        <CarouselNext />
      </Carousel>
    </div>
  )
}
<Carousel opts={{ align: "start" }}>
  <CarouselContent>
    <CarouselItem className="basis-1/2 sm:basis-1/3">…</CarouselItem>
  </CarouselContent>
</Carousel>

Spacing

Slides are separated by padding on CarouselItem and a matching negative margin on CarouselContent (16px by default). To change the gap, change both: -ml-2 on the content and pl-2 on each item.

Vertical

orientation="vertical" scrolls on the y-axis. Give CarouselContent a fixed height; the buttons move above and below.

09:00
Design reviewStudio A
10:30
Tokens syncRemote
12:00
Lunch & learn: motionAtrium
14:00
Accessibility auditStudio B
16:30
Release planningRemote
import { Card, CardContent } from "@/components/ui/card"
import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from "@/components/ui/carousel"

const events = [
  { time: "09:00", title: "Design review", room: "Studio A" },
  { time: "10:30", title: "Tokens sync", room: "Remote" },
  { time: "12:00", title: "Lunch & learn: motion", room: "Atrium" },
  { time: "14:00", title: "Accessibility audit", room: "Studio B" },
  { time: "16:30", title: "Release planning", room: "Remote" },
]

export default function CarouselVertical() {
  return (
    <Carousel
      orientation="vertical"
      opts={{ align: "start" }}
      className="my-12 w-full max-w-xs"
      aria-label="Today's schedule"
    >
      <CarouselContent className="h-60">
        {events.map((event, index) => (
          <CarouselItem
            key={event.title}
            className="basis-1/3"
            aria-label={`${index + 1} of ${events.length}`}
          >
            <Card size="sm" className="py-3">
              <CardContent className="flex items-center gap-3">
                <span className="font-mono text-xs text-muted-foreground tabular-nums">
                  {event.time}
                </span>
                <div className="flex min-w-0 flex-col">
                  <span className="truncate text-sm font-medium">
                    {event.title}
                  </span>
                  <span className="text-xs text-muted-foreground">
                    {event.room}
                  </span>
                </div>
              </CardContent>
            </Card>
          </CarouselItem>
        ))}
      </CarouselContent>
      <CarouselPrevious />
      <CarouselNext />
    </Carousel>
  )
}

With the API

setApi hands you the Embla instance. Use it to build a slide counter, pagination dots or thumbnails, and to react to select events.

01Dashboard
02Settings
03Billing
04Activity
05Team
1 / 5
"use client"

import * as React from "react"

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

const shots = [
  { title: "Dashboard" },
  { title: "Settings" },
  { title: "Billing" },
  { title: "Activity" },
  { title: "Team" },
]

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

  React.useEffect(() => {
    if (!api) return
    const onSelect = () => setCurrent(api.selectedScrollSnap())
    api.on("select", onSelect)
    api.on("reInit", onSelect)
    return () => {
      api.off("select", onSelect)
      api.off("reInit", onSelect)
    }
  }, [api])

  return (
    // px-12 leaves room for the previous and next buttons, which sit outside the slides.
    <div className="flex w-full max-w-md flex-col items-center gap-4 px-12">
      <Carousel
        setApi={setApi}
        className="w-full"
        aria-label="Product screenshots"
      >
        <CarouselContent>
          {shots.map((shot, index) => (
            <CarouselItem
              key={shot.title}
              aria-label={`${index + 1} of ${shots.length}`}
            >
              <div className="flex aspect-[16/10] flex-col justify-between rounded-xl bg-muted p-5">
                <span className="font-mono text-xs text-muted-foreground numeric">
                  {String(index + 1).padStart(2, "0")}
                </span>
                <span className="text-heading-sm text-foreground">
                  {shot.title}
                </span>
              </div>
            </CarouselItem>
          ))}
        </CarouselContent>
        <CarouselPrevious />
        <CarouselNext />
      </Carousel>
      <div className="flex items-center gap-4">
        <div className="flex">
          {shots.map((shot, index) => (
            // A 24px target around a 6px dot (WCAG 2.5.8).
            <button
              key={shot.title}
              type="button"
              aria-label={`Go to slide ${index + 1}: ${shot.title}`}
              aria-current={index === current ? "true" : undefined}
              onClick={() => api?.scrollTo(index)}
              className="group/dot flex h-6 min-w-6 items-center justify-center rounded-full focus-ring-inset"
            >
              <span
                className={cn(
                  "h-1.5 rounded-full transition-[width,background-color] duration-base ease-standard",
                  index === current
                    ? "w-5 bg-foreground"
                    : "w-1.5 bg-gray-7 group-hover/dot:bg-gray-9"
                )}
              />
            </button>
          ))}
        </div>
        <span
          className="text-xs text-muted-foreground tabular-nums"
          aria-live="polite"
        >
          {current + 1} / {shots.length}
        </span>
      </div>
    </div>
  )
}
const [api, setApi] = React.useState<CarouselApi>()

React.useEffect(() => {
  if (!api) return
  const onSelect = () => setCurrent(api.selectedScrollSnap())
  api.on("select", onSelect)
  return () => {
    api.off("select", onSelect)
  }
}, [api])

<Carousel setApi={setApi}>…</Carousel>

Guidelines

When to use

  • To browse a set of peer items where seeing a few at a time is enough: screenshots, templates, product photos, testimonials.
  • On touch devices, where swiping through a set is natural.

When not to use

  • For content everyone must see. Slides after the first are rarely viewed — put important information on the page.
  • For navigation or comparison — use Tabs, a grid, or a Table.
  • For a single hero image — just show the image.

Show that there's more

Let the next slide peek in, show a count or dots, and keep the previous button disabled on the first slide. People should never wonder whether there is more.

1 / 4

DoControls and a position indicator make the set's size obvious.
Next slide in 3s…
Don’tAuto-advancing slides with no controls move content away before people finish reading.

Autoplay

prfct doesn't autoplay. If you add the Embla autoplay plugin, pause on hover and focus, provide a visible pause button, and don't autoplay at all when prefers-reduced-motion is set. Content that moves on its own for more than five seconds must be pausable (WCAG 2.2.2).

Accessibility

The carousel is a region with aria-roledescription="carousel", and every slide is a group with aria-roledescription="slide". Both descriptions are built-in strings, so LocaleProvider translates them: with the Polish locale they read "karuzela" and "slajd".

KeyBehavior
Tab
Moves focus into the slides and to the previous and next buttons.
←
Scrolls to the previous slide when focus is inside a horizontal carousel.
→
Scrolls to the next slide when focus is inside a horizontal carousel.
↑↓
Scroll to the previous and next slide in a vertical carousel.
EnterSpace
Activates the focused previous or next button.
  • Arrow keys stay with fields. Inside an input, a textarea, a select or editable text, arrow keys move the caret, not the slides.
  • Name the carousel. Give Carousel an aria-label that says what the slides are: "Product screenshots".
  • Label slides by position. Give each CarouselItem an aria-label such as "2 of 5", so people know where they are.
  • Buttons are labeled. CarouselPrevious and CarouselNext include "Previous slide" and "Next slide" for screen readers — translated by LocaleProvider — and are disabled at the ends unless the carousel loops.
  • Text on images. Keep 4.5:1 contrast for text over imagery: put a scrim between the image and the text.

API reference

The root. Accepts all div props.

PropTypeDefault
opts

Embla options: align, loop, dragFree, slidesToScroll, breakpoints and more.

EmblaOptionsTypeNo default
plugins

Embla plugins, such as autoplay or wheel gestures.

EmblaPluginType[]No default
orientation

Scroll axis. Vertical carousels need a fixed content height.

"horizontal" | "vertical""horizontal"
setApi

Receives the Embla instance once it's ready.

(api: CarouselApi) => voidNo default

CarouselContent

The scrolling track. Accepts all div props; its negative margin pairs with the item padding to form the gap.

CarouselItem

A single slide, full width by default. Set basis-* to show several at once.

CarouselPrevious, CarouselNext

Outline icon buttons wired to the carousel. They accept all Button props; variant defaults to "outline" and size to "icon-sm".

useCarousel

Returns { api, scrollPrev, scrollNext, canScrollPrev, canScrollNext, orientation } for building custom controls inside a Carousel.