Carousel
A slide track with previous and next controls — horizontal or vertical, any slide size or spacing, plus Embla's options, events and plugins.
- Status
- Since
0.10.0- Accessibility pattern
- labelled carousel region with slide groups
Last updated
Install
Add Carousel from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.
pnpm dlx shadcn@latest add @vegastack/carouselThe same command installs the registry items it composes: @vegastack/button.
It also adds the sanctioned engine to your package.json: embla-carousel-react (slide/scroll-snap engine).
Usage
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@/components/ui/carousel";
<Carousel>
<CarouselContent>
<CarouselItem>...</CarouselItem>
<CarouselItem>...</CarouselItem>
<CarouselItem>...</CarouselItem>
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>;Anatomy
Examples
About
The carousel is built on Embla Carousel. Embla owns the scroll snapping, the drag and its momentum, the slide index and whether there is anywhere left to scroll; the arrows, the slide chrome and the arrow-key handler are ours. That division is why the carousel below disables its previous arrow on the first slide without being told to.
Composition
CarouselContent is the scroll viewport and the flex track inside it; each CarouselItem is one
slide. CarouselPrevious and CarouselNext are Buttons positioned outside the track, so give the
carousel horizontal room for them or move them with className.
Carousel
├── CarouselContent
│ ├── CarouselItem
│ └── CarouselItem
├── CarouselPrevious
└── CarouselNextSizes
Slide width is the basis-* utility on CarouselItem, which is why it is responsive for free.
// 33% of the carousel width.
<CarouselItem className="basis-1/3">...</CarouselItem>
// 50% on small screens, 33% on larger ones.
<CarouselItem className="md:basis-1/2 lg:basis-1/3">...</CarouselItem>Spacing
The gap between slides is a negative margin on CarouselContent matched by padding on
CarouselItem, so the first slide still sits flush with the viewport edge. The component's own
default is -ms-4 / ps-4; override both together.
<CarouselContent className="-ml-2 md:-ml-4">
<CarouselItem className="pl-2 md:pl-4">...</CarouselItem>
</CarouselContent>Orientation
orientation takes horizontal (the default) and vertical. It sets Embla's axis, swaps the
spacing utilities for their vertical pair, and rotates the two arrows onto the top and bottom edges.
A vertical carousel needs an explicit height on CarouselContent.
Options
opts is passed straight to Embla — see the
Embla options reference. loop and align are the
two that change what the reader sees: with loop: true the track wraps, so neither arrow ever
disables.
<Carousel opts={{ align: "start", loop: true }}>...</Carousel>API
setApi hands you the Embla instance, which is how anything outside the carousel reads or drives
it — a slide counter, dots, a "jump to slide" control.
const [api, setApi] = React.useState<CarouselApi>();
React.useEffect(() => {
if (!api) return;
setCount(api.scrollSnapList().length);
setCurrent(api.selectedScrollSnap() + 1);
}, [api]);
<Carousel setApi={setApi}>...</Carousel>;Events
The same instance emits Embla's events — see the
Embla events reference. select fires when the
settled slide changes, whatever moved it: an arrow, a drag, an arrow key or a programmatic
scrollTo. Unsubscribe in the effect's cleanup, or a remount leaves the old handler attached.
React.useEffect(() => {
if (!api) return;
const onSelect = () => {
// Do something on select.
};
api.on("select", onSelect);
return () => {
api.off("select", onSelect);
};
}, [api]);Plugins
plugins takes Embla plugins — objects with a name, an options bag, an init(embla) and a
destroy(). The carousel hands each one the engine instance, and Embla tears them down with it.
Keep the instance in a ref: re-creating it on every render rebuilds the engine.
The published plugin most people reach for first is autoplay, which is a separate package:
npm install embla-carousel-autoplayimport Autoplay from "embla-carousel-autoplay";
const plugin = React.useRef(Autoplay({ delay: 2000, stopOnInteraction: true }));
<Carousel
plugins={[plugin.current]}
onMouseEnter={plugin.current.stop}
onMouseLeave={plugin.current.reset}
>
...
</Carousel>;That package is deliberately not installed in this design system, so the live example below uses a
small plugin written inline instead: it subscribes to the engine's select and reInit events and
reports the settled slide. It is the same protocol autoplay uses, minus the timer.
RTL
Set both dir="rtl" and Embla's own direction option — dir alone mirrors the layout while the
engine keeps scrolling left to right. The arrows carry rtl:rotate-180, so they point the way the
track moves.
<Carousel dir="rtl" opts={{ direction: "rtl" }}>
...
</Carousel>API Reference
Carousel
| Prop | Type | Default | Description |
|---|---|---|---|
opts | Partial<OptionsType> | — | |
orientation | "horizontal" | "vertical" | — | |
plugins | CreatePluginType<LoosePluginType, {}>[] | — | |
setApi | ((api: CarouselApi) => void) | — |
CarouselContent
CarouselContent adds no props of its own — it accepts everything the underlying element or Base UI primitive accepts (className, ref, ARIA attributes, event handlers).
CarouselItem
CarouselItem adds no props of its own — it accepts everything the underlying element or Base UI primitive accepts (className, ref, ARIA attributes, event handlers).
CarouselPrevious
| Prop | Type | Default | Description |
|---|---|---|---|
loading | boolean | — | Shows a spinner over the label, blocks activation and sets aria-busy. The label keeps its
box at opacity: 0, so the button's width does not move and its accessible name survives
(API-5, A11Y-12). |
size | "default" | "icon" | "icon-lg" | "icon-sm" | "icon-xs" | "lg" | "sm" | "xs" | — | |
variant | "default" | "destructive" | "ghost" | "link" | "outline" | "secondary" | — |
CarouselNext
| Prop | Type | Default | Description |
|---|---|---|---|
loading | boolean | — | Shows a spinner over the label, blocks activation and sets aria-busy. The label keeps its
box at opacity: 0, so the button's width does not move and its accessible name survives
(API-5, A11Y-12). |
size | "default" | "icon" | "icon-lg" | "icon-sm" | "icon-xs" | "lg" | "sm" | "xs" | — | |
variant | "default" | "destructive" | "ghost" | "link" | "outline" | "secondary" | — |
useCarousel() returns the same context the arrows read — api, scrollPrev, scrollNext,
canScrollPrev, canScrollNext and orientation — for a control of your own inside the carousel.
CarouselApi is the Embla instance type; see the
Embla API reference for what it exposes.
Accessibility
- The root is
role="region"witharia-roledescription="carousel", and every slide isrole="group"witharia-roledescription="slide", which is the ARIA carousel pattern. Give the root anaria-labelnaming what it holds — the roledescription says it is a carousel, not what is in it. - The arrows are
Buttons with ansr-only"Previous slide" / "Next slide" name, and they disable at the ends of a non-looping track, so the control's state matches what the engine can actually do. - ← and → scroll the track while focus is anywhere inside the carousel, so a keyboard user is not forced through the arrows.
- The slide track scrolls but takes no focus of its own; put the reachable content inside the slides. Nothing in the carousel is revealed by hover alone.
- Focus is the global 2px
:focus-visibleoutline frombase.css, inherited fromButton; there is no ring glow.
| Contract | States tested |
|---|---|
| Behaviour | default, at-start, at-end, scrolling, horizontal, vertical |
| Accessibility | native-or-base-ui-semantics, browser-accessibility-test, keyboard-navigation, labeled, disabled |
| Visual | default, hover, disabled, focus-visible |
Do / Don't
Deviations
Upstream's file plus packages/ui/upstream/patches/carousel.patch. Every hunk:
- No styling hunk. New to this system in Batch 5. The arrows render through
Button, which already owns focus, cursor and disabled behaviour, and the slide track has no focus affordance of its own.embla-carousel-reactis the engine; it is pre-approved with upstream's whole dependency set (DOC-7). - DOC-2 —
import { cn } from "cn"becomes@vegastack/design; our npm layer ownscn. - DOC-1 — prettier reflow plus the
registry-stamp.mjsprovenance header the three-copy model needs; no decision of its own.