Components
A carousel with motion and swipe built using Embla.
Playground
ring-inset: the scroll viewport clips exactly at the slide edge, which would cut off the Card's outer ring.import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@workspace/ui/components/carousel"
<Carousel className="w-full max-w-xs">
<CarouselContent>
{Array.from({ length: 5 }, (_, index) => (
<CarouselItem key={index}>
<Card className="py-0 ring-inset">
<CardContent className="flex aspect-square items-center justify-center p-6">
<span className="text-4xl font-medium">{index + 1}</span>
</CardContent>
</Card>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>basis-full by default. Set a basis-* utility on the items to show more than one per view — responsive variants like md:basis-1/2 lg:basis-1/3 work too.<Carousel opts={{ align: "start" }} className="w-full max-w-xs">
<CarouselContent>
{Array.from({ length: 5 }, (_, index) => (
<CarouselItem key={index} className="basis-1/3">
<Card className="py-0 ring-inset">
<CardContent className="flex aspect-square items-center justify-center p-2">
<span className="text-2xl font-medium">{index + 1}</span>
</CardContent>
</Card>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>-ms-4 / ps-4 by default. Override both together, using logical properties (ms / ps) so RTL keeps working.<Carousel opts={{ align: "start" }} className="w-full max-w-xs">
<CarouselContent className="-ms-2">
{Array.from({ length: 5 }, (_, index) => (
<CarouselItem key={index} className="basis-1/3 ps-2">
<Card className="py-0 ring-inset">
<CardContent className="flex aspect-square items-center justify-center p-2">
<span className="text-2xl font-medium">{index + 1}</span>
</CardContent>
</Card>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>orientation="vertical" stacks the slides and moves the arrows above and below the content. Give CarouselContent an explicit height — slides fill it according to their basis.<Carousel
orientation="vertical"
opts={{ align: "start" }}
className="w-full max-w-xs"
>
<CarouselContent className="h-56">
{Array.from({ length: 5 }, (_, index) => (
<CarouselItem key={index} className="basis-1/2">
<Card className="h-full py-0 ring-inset">
<CardContent className="flex h-full items-center justify-center p-6">
<span className="text-2xl font-medium">{index + 1}</span>
</CardContent>
</Card>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>opts prop is forwarded to Embla — loop, align, dragFree, skipSnaps, and the rest of its options work as documented upstream. With loop the arrows never disable.<Carousel opts={{ align: "start", loop: true }} className="w-full max-w-xs">
<CarouselContent>
{Array.from({ length: 5 }, (_, index) => (
<CarouselItem key={index} className="basis-1/3">
<Card className="py-0 ring-inset">
<CardContent className="flex aspect-square items-center justify-center p-2">
<span className="text-2xl font-medium">{index + 1}</span>
</CardContent>
</Card>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>setApi to get the Embla CarouselApi instance, then subscribe to its events. Reading state like the selected slide belongs in useSyncExternalStore — the same pattern the component uses internally for the arrow buttons.import {
Carousel,
type CarouselApi,
} from "@workspace/ui/components/carousel"
const [api, setApi] = React.useState<CarouselApi>()
const subscribe = React.useCallback(
(onStoreChange: () => void) => {
if (!api) return () => {}
api.on("select", onStoreChange)
api.on("reInit", onStoreChange)
return () => {
api.off("select", onStoreChange)
api.off("reInit", onStoreChange)
}
},
[api]
)
const current = React.useSyncExternalStore(
subscribe,
() => (api ? api.selectedScrollSnap() + 1 : 0),
() => 0
)
const count = React.useSyncExternalStore(
subscribe,
() => api?.scrollSnapList().length ?? 0,
() => 0
)
<Carousel setApi={setApi} className="w-full max-w-xs">
{/* ... */}
</Carousel>
<div className="py-2 text-center text-sm text-muted-foreground">
Slide {current} of {count}
</div>dir="rtl" mirrors the arrows and their caret icons automatically. Embla needs to know too: pass direction: "rtl" in opts so dragging and scrolling reverse as well.<Carousel
dir="rtl"
opts={{ direction: "rtl" }}
className="w-full max-w-xs"
>
<CarouselContent>
{Array.from({ length: 5 }, (_, index) => (
<CarouselItem key={index}>
<Card className="py-0 ring-inset">
<CardContent className="flex aspect-square items-center justify-center p-6">
<span className="text-4xl font-medium">{index + 1}</span>
</CardContent>
</Card>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>role="region" with aria-roledescription="carousel" and each slide as a role="group" with aria-roledescription="slide". Arrow keys (← / →) scroll while focus is inside, the arrow buttons carry sr-only labels and disable at the edges. Name the region yourself with aria-label when the page has more than one carousel.<Carousel aria-label="Featured photos" className="w-full max-w-xs">
<CarouselContent>
<CarouselItem>{/* role="group" aria-roledescription="slide" */}</CarouselItem>
</CarouselContent>
{/* sr-only "Previous slide" / "Next slide" labels built in */}
<CarouselPrevious />
<CarouselNext />
</Carousel>plugins prop accepts Embla plugins (Autoplay, WheelGestures, …) once their packages are added, and the useCarousel hook exposes the context to custom parts. All parts accept their underlying element props.| Component | Prop | Default |
|---|---|---|
| Carousel | opts?: CarouselOptions, plugins?: CarouselPlugin, setApi?: (api: CarouselApi) => void | — |
| Carousel | orientation?: "horizontal" | "vertical" | "horizontal" |
| CarouselItem | className — basis-* controls slides per view | basis-full |
| CarouselPrevious | variant?: Button variant, size?: Button size | "outline" / "icon-sm" |
| CarouselNext | variant?: Button variant, size?: Button size | "outline" / "icon-sm" |
<Carousel data-slot="carousel" /> <CarouselContent data-slot="carousel-content" /> <CarouselItem data-slot="carousel-item" /> <CarouselPrevious data-slot="carousel-previous" /> <CarouselNext data-slot="carousel-next" />
Tokens used
8 design tokens consumed by the Carousel component.
--background
Background
--foreground
Foreground
--muted
Muted
--border
Border
--input
Input
--ring
Ring
--text-sm
Text Small (14px)
--spacing
Spacing unit (4px)