Carousel
A horizontally or vertically scrolling set of slides with swipe, keyboard and button navigation, built on Embla.
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
- 1ContentThe viewport. It clips the slides that are off screen and follows a drag or a swipe.
- 2PreviousMoves one slide back. Disabled on the first slide unless the carousel loops.
- 3ItemOne slide, a group announced as a slide. Label it by its position.
- 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.jsonUsage
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.
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.
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.
"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
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".
| Key | Behavior |
|---|---|
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
Carouselanaria-labelthat says what the slides are: "Product screenshots". - Label slides by position. Give each
CarouselItemanaria-labelsuch as "2 of 5", so people know where they are. - Buttons are labeled.
CarouselPreviousandCarouselNextinclude "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
Carousel
The root. Accepts all div props.
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.