Skip to content
Component installs need the registry setup— the Base UI shadcn project, the @vegastack namespace and the Cloudflare Access service token.
VegaStack Design

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
stable
Since
0.10.0
Accessibility pattern
labelled carousel region with slide groups

Last updated

1
2
3
4
5

Install

Add Carousel from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.

pnpm dlx shadcn@latest add @vegastack/carousel

The 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>;
1
2
3
4
5

Anatomy

Carousel — data-slot="carousel"
CarouselContent — data-slot="carousel-content"
CarouselItem — data-slot="carousel-item"
CarouselNext — data-slot="carousel-next"
CarouselPrevious — data-slot="carousel-previous"

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.

1
2
3

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
└── CarouselNext
1
2

Sizes

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>
1
2
3
4
5

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>
1
2
3
4
5

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.

1
2
3
4
5

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>
1
2
3
4
5

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>;
1
2
3
4
5
Slide 0 of 0

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]);
1
2
3
4
5
select fired 0× · settle fired 0×

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-autoplay
import 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.

1
2
3
4
5
The plugin reports: slide 0 of 0

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

PropTypeDefaultDescription
optsPartial<OptionsType>
orientation"horizontal" | "vertical"
pluginsCreatePluginType<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

PropTypeDefaultDescription
loadingbooleanShows 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

PropTypeDefaultDescription
loadingbooleanShows 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" with aria-roledescription="carousel", and every slide is role="group" with aria-roledescription="slide", which is the ARIA carousel pattern. Give the root an aria-label naming what it holds — the roledescription says it is a carousel, not what is in it.
  • The arrows are Buttons with an sr-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-visible outline from base.css, inherited from Button; there is no ring glow.
ContractStates tested
Behaviourdefault, at-start, at-end, scrolling, horizontal, vertical
Accessibilitynative-or-base-ui-semantics, browser-accessibility-test, keyboard-navigation, labeled, disabled
Visualdefault, hover, disabled, focus-visible

Do / Don't

Do
Use a Carousel for a run of peer items the reader can browse — a gallery, feature cards, testimonials — and give the region an aria-label.
Don't
Put content the reader must not miss behind a Carousel, or autoplay it without a way to stop; a list they need to compare belongs in the page.

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-react is the engine; it is pre-approved with upstream's whole dependency set (DOC-7).
  • DOC-2import { cn } from "cn" becomes @vegastack/design; our npm layer owns cn.
  • DOC-1 — prettier reflow plus the registry-stamp.mjs provenance header the three-copy model needs; no decision of its own.

On this page