A swipeable, scrollable set of slides built on Embla Carousel. Horizontal or vertical, one or many slides per view, looping, previous and next buttons that disable at the ends, arrow-key navigation, WAI-ARIA labels and slide announcements, and full access to the Embla API for counters, dots, thumbnails and autoplay.
Carousel passes opts and plugins straight to Embla's
useEmblaCarousel, so every Embla option
(loop, align, slidesToScroll, dragFree, startIndex, …) and plugin
works. orientation sets Embla's axis for you.
<Carousel> {/* <section aria-roledescription="carousel" aria-label>, Embla + context */} <CarouselContent> {/* viewport (overflow-hidden) › flex track (id for aria-controls) */} <CarouselItem /> {/* role="group" aria-roledescription="slide" aria-label="1 of 5" */} </CarouselContent> <CarouselPrevious /> {/* Button, disabled at the start */} <CarouselNext /> {/* Button, disabled at the end */} {/* visually hidden aria-live="polite" region, rendered for you */}</Carousel>
Part
Renders
Notes
Carousel
<section>
Creates the Embla instance, provides context, names the region, and handles the arrow keys for anything focused inside. Also renders the visually hidden live region. relative, so the buttons position against it.
CarouselContent
<div> › <div>
The outer div is Embla's viewport (overflow-hidden); the inner div is the flex track and receives your className and props. A negative margin offsets the items' gutter.
CarouselItem
<div role="group">
One slide, labelled with its position ("2 of 5") automatically. basis-full by default, with a 16px leading gutter (pl-4, or pt-4 vertically).
Set a basis-* class on CarouselItem to show several slides at once, with responsive variants like md:basis-1/3. opts={{ align: "start" }} aligns snaps to the leading edge instead of centering them.
The gap between slides is the items' leading padding, balanced by a negative margin on the content. Change both together: -ml-2 on CarouselContent and pl-2 on each CarouselItem for an 8px gap.
orientation="vertical" scrolls on the y axis and moves the buttons above and below. Give CarouselContent a fixed height, and use -mt-* / pt-* for spacing. The keyboard follows the axis: ↑ and ↓ move between slides in a vertical carousel.
The buttons are absolutely positioned outside the carousel by default, which needs horizontal room. Override the position with className to place them over the slides; tailwind-merge replaces the default offsets.
Pass setApi to receive the Embla API once it's ready. Subscribe to its select and reInit events to track the current slide, and unsubscribe in the effect cleanup. The carousel already announces slide changes, so a visible counter like this one is aria-hidden rather than a second live region.
With the API, scrollTo(index) jumps to a slide and selectedScrollSnap() tells you which dot is active. Mark the active dot with aria-current and label each dot with its slide number.
Call api.scrollNext() on an interval, with loop so it wraps. Pause while the pointer or focus is inside, provide a visible pause button, and start paused when the user prefers reduced motion. Pass announce={!playing} so the live region stays quiet while slides rotate on their own. Embla's autoplay plugin (embla-carousel-autoplay, installed separately) can be passed through plugins instead.
Usage-based billing is livePay only for the events you send.
New: audit log exportDownload a CSV of every change.
SOC 2 Type II reportAvailable to all Team customers.
import { PauseIcon, PlayIcon } from "lucide-react";import { useEffect, useState } from "react";import { Button } from "@/components/ui/button";import { Card, CardContent } from "@/components/ui/card";import { Carousel, type CarouselApi, CarouselContent, CarouselItem,} from "@/components/ui/carousel";const announcements = [ { title: "Usage-based billing is live", text: "Pay only for the events you send.", }, { title: "New: audit log export", text: "Download a CSV of every change." }, { title: "SOC 2 Type II report", text: "Available to all Team customers." },];export default function CarouselAutoplay() { const [api, setApi] = useState<CarouselApi>(); const [playing, setPlaying] = useState(true); const [paused, setPaused] = useState(false); // Start paused for people who prefer reduced motion. useEffect(() => { if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { setPlaying(false); } }, []); useEffect(() => { if (!api || !playing || paused) return; const id = window.setInterval(() => api.scrollNext(), 4000); return () => window.clearInterval(id); }, [api, playing, paused]); return ( <div className="flex w-full max-w-sm flex-col gap-2"> <Carousel aria-label="Announcements" setApi={setApi} opts={{ loop: true }} // Rotating slides shouldn't interrupt; announce only when stopped. announce={!playing} onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)} onFocus={() => setPaused(true)} onBlur={() => setPaused(false)} > <CarouselContent> {announcements.map((a) => ( <CarouselItem key={a.title}> <Card size="sm"> <CardContent className="flex flex-col gap-1 py-4"> <span className="font-medium text-sm">{a.title}</span> <span className="text-muted-foreground text-xs"> {a.text} </span> </CardContent> </Card> </CarouselItem> ))} </CarouselContent> </Carousel> <Button variant="ghost" size="xs" className="self-end" onPress={() => setPlaying((p) => !p)} > {playing ? <PauseIcon /> : <PlayIcon />} {playing ? "Pause" : "Play"} </Button> </div> );}
Two synced carousels: selecting a slide in the main one scrolls the thumbnail strip, and pressing a thumbnail calls scrollTo on the main API. The strip uses dragFree and containScroll: "keepSnaps", and announce={false} so only the main carousel speaks.
A step-by-step flow inside a Card. watchDrag: false disables swiping so users move only with the buttons, and a custom footer reads useCarousel() to render Back, Continue and a step counter. The footer counter is the live region, so the carousel's own is turned off with announce={false}, and each slide keeps a custom aria-label ("Step 2 of 3").
Connect a data source
Pick Postgres, BigQuery or one of 40 SaaS connectors. Read-only credentials are enough.
Build a dashboard
Start from a template or drag metrics onto a blank canvas. Everything updates live.
Invite your team
Share dashboards with a link, or add teammates with viewer, editor or admin roles.
Step 1 of 3
import { BarChart3Icon, PlugIcon, UsersIcon } from "lucide-react";import { useEffect, useState } from "react";import { Button } from "@/components/ui/button";import { Card, CardDescription, CardFooter, CardHeader, CardTitle,} from "@/components/ui/card";import { Carousel, CarouselContent, CarouselItem, useCarousel,} from "@/components/ui/carousel";const steps = [ { icon: PlugIcon, title: "Connect a data source", text: "Pick Postgres, BigQuery or one of 40 SaaS connectors. Read-only credentials are enough.", }, { icon: BarChart3Icon, title: "Build a dashboard", text: "Start from a template or drag metrics onto a blank canvas. Everything updates live.", }, { icon: UsersIcon, title: "Invite your team", text: "Share dashboards with a link, or add teammates with viewer, editor or admin roles.", },];/** Custom controls: any component inside <Carousel> can call useCarousel(). */function StepControls() { const { api, scrollPrev, scrollNext, canScrollPrev, canScrollNext } = useCarousel(); const [index, setIndex] = useState(0); useEffect(() => { if (!api) return; const onSelect = () => setIndex(api.selectedScrollSnap()); onSelect(); api.on("select", onSelect); return () => { api.off("select", onSelect); }; }, [api]); return ( <CardFooter className="justify-between border-t"> <span className="text-muted-foreground text-xs" aria-live="polite"> Step {index + 1} of {steps.length} </span> <div className="flex gap-2"> <Button variant="ghost" size="sm" isDisabled={!canScrollPrev} onPress={scrollPrev} > Back </Button> {canScrollNext ? ( <Button size="sm" onPress={scrollNext}> Continue </Button> ) : ( <Button size="sm">Get started</Button> )} </div> </CardFooter> );}export default function CarouselRecipeOnboarding() { return ( <Card className="w-full max-w-sm overflow-hidden"> <Carousel aria-label="Getting started" opts={{ watchDrag: false }} // The footer's step counter is the live region here. announce={false} className="flex flex-col gap-4" > <CarouselContent> {steps.map((s, i) => ( <CarouselItem key={s.title} aria-label={`Step ${i + 1} of ${steps.length}`} > <CardHeader> <span className="mb-3 flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary"> <s.icon className="size-5" aria-hidden /> </span> <CardTitle>{s.title}</CardTitle> <CardDescription>{s.text}</CardDescription> </CardHeader> </CarouselItem> ))} </CarouselContent> <StepControls /> </Carousel> </Card> );}
Carousel renders a <section> with aria-roledescription="carousel" and an accessible name. Pass aria-label (for example "Product photos") or aria-labelledby; otherwise the label prop is used, which defaults to "Carousel". Always give a descriptive name when a page has more than one carousel.
Each CarouselItem has role="group", aria-roledescription="slide" and a position label such as "2 of 5", computed from Embla's slide list and updated on reInit. Pass aria-label or aria-labelledby on an item to replace it, or formatSlideLabel on Carousel to change the wording for every slide (for example to translate it).
A visually hidden aria-live="polite" region announces the selection when it changes: "Slide 3 of 5", or "Slides 4 to 6 of 9" when several slides share a snap. Nothing is announced on first render. Change the text with formatAnnouncement, and set announce={false} while auto-rotating or when you render your own live counter.
The previous and next buttons are labelled "Previous slide" and "Next slide", reference the slide track with aria-controls, and are disabled at the ends (unless loop is on). Pass aria-label to override the labels.
Arrow keys follow the orientation: ← / → when horizontal (swapped when opts.direction is "rtl" or the carousel sits in a right-to-left context), ↑ / ↓ when vertical. They work whenever focus is inside the carousel, run in the capture phase and prevent the default, but are ignored while typing in an input, textarea, select or contenteditable element.
Slides outside the viewport stay in the DOM and in the tab order. Keep slide content focusable only when it's useful, and consider inert on off-screen slides for heavy interactive content.
Autoplay must have a visible pause control, pause on hover and focus, and respect prefers-reduced-motion (see Autoplay).
Gap: -ml-* on CarouselContent and pl-* on CarouselItem (or -mt-* / pt-* vertically).
Buttons: variant, size and className on CarouselPrevious / CarouselNext. They're outline and icon-sm by default, rounded, with the arrow rotated 90° in vertical mode.
Height (vertical): a fixed h-* on CarouselContent.
No extra props. CarouselContent passes className and all props to the inner track <div>, which gets a generated id (used by the buttons' aria-controls) unless you pass one. CarouselItem accepts every prop of <div>, including ref; its aria-label defaults to the slide position.
The Embla instance type. Common methods: scrollNext(), scrollPrev(), scrollTo(index), selectedScrollSnap(), scrollSnapList(), canScrollNext(), canScrollPrev(), slidesInView(), on(event, fn) and off(event, fn). See the Embla API.