Skip to content

ComponentsDisplay

Carousel

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.

Embla CarouselSource
DashboardsEvery metric in one place.
AlertsKnow before your users do.
ReportsScheduled PDFs for the board.
IntegrationsConnect 40+ data sources.
Audit logEvery change, attributed.

Installation

pnpm dlx shadcn@latest add @desyne/carousel

The CLI installs dependencies and any other components this one uses.

The install adds embla-carousel-react as a dependency.

Usage

tsx
import {
  Carousel,
  type CarouselApi,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
  useCarousel,
} from "@/components/ui/carousel";
tsx
<Carousel aria-label="Product photos">
  <CarouselContent>
    <CarouselItem>…</CarouselItem>
    <CarouselItem>…</CarouselItem>
    <CarouselItem>…</CarouselItem>
  </CarouselContent>
  <CarouselPrevious />
  <CarouselNext />
</Carousel>

Embla does the scrolling

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.

When to use

  • Carousel: a small set of peer items where showing one (or a few) at a time saves space, such as product photos, testimonials or onboarding steps.
  • Tabs: when each panel has a name the user should pick directly.
  • A plain grid or scrolling row: when users need to scan or compare everything. Content hidden in later slides is often never seen.
  • Avoid autoplay for important content, and never put the only copy of critical information in a carousel.

Anatomy

tsx
<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>
PartRendersNotes
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).
CarouselPreviousButtonRound outline icon button placed outside the left (or top) edge, with aria-controls pointing at the track. Disabled when Embla can't scroll back.
CarouselNextButtonSame, on the right (or bottom) edge.
useCarouselhookContext for custom controls: api, scrollPrev, scrollNext, canScrollPrev, canScrollNext, orientation, contentId.

Examples

Multiple slides per view

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.

Invoice
Roadmap
OKRs
Retro
Onboarding
Changelog
Postmortem
Budget

Spacing

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.

1
2
3
4
5
6

Vertical

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.

09:00
StandupZoom
10:30
Design reviewRoom 4B
13:00
Lunch with MayaCafé Lisboa
15:00
Roadmap planningRoom 2A
17:30
1:1 with JacksonZoom

Loop

opts={{ loop: true }} wraps from the last slide to the first. The previous and next buttons never disable.

Lisbon
Kyoto
Oaxaca
Tallinn
Cape Town

Controls inside

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.

Spring collection
Linen essentials
Weekend travel

API and slide counter

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.

Harbor
Old town
Tram 28
Sunset
Market

Dot indicators

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.

Invite your team
Connect a data source
Build your first dashboard
Share with stakeholders

Autoplay

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.

Recipes

Testimonials

Two cards per view on larger screens, one on mobile, with looping. Cards use h-full so every slide matches the tallest.

We replaced three internal tools in a week. Our on-call rotation finally has one place to look.
Amara OkaforHead of SRE, Fieldline
The audit log alone paid for it. Our SOC 2 evidence collection went from days to minutes.
Kenji TanakaSecurity Lead, Monoform
Finance and engineering look at the same dashboard now. That used to be a monthly argument.
Sofia DavisVP Finance, Northwind
Setup took an afternoon. The SDKs are small and the docs answered every question we had.
Lucas BrownStaff Engineer, Parcelly

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.

Oak lounge chair, front view
Oak lounge chair, side view
Close-up of the woven seat
Chair in a living room
Two chairs facing each other

Onboarding steps

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

Accessibility

Follows the WAI-ARIA carousel pattern out of the box:

  • 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).

Keyboard

KeyAction
TabMoves focus to the previous and next buttons and to focusable content in the slides
← / →Horizontal: previous / next slide while focus is inside the carousel (reversed in right-to-left)
↑ / ↓Vertical: previous / next slide while focus is inside the carousel
Space / EnterActivates the focused previous or next button

Styling

Data attributes

AttributeOnPresent when
data-slot="carousel"CarouselAlways
data-orientationCarouselAlways, "horizontal" or "vertical"
data-slot="carousel-content"viewport divAlways
data-slot="carousel-item"CarouselItemAlways
data-slot="carousel-previous"CarouselPreviousAlways
data-slot="carousel-next"CarouselNextAlways
data-slot="carousel-announcer"live regionannounce is on
data-disabledprevious / next buttonsThe carousel can't scroll that way

The buttons also carry the usual Button state attributes (data-hovered, data-pressed, data-focus-visible).

Customizing

  • Slide width: basis-* on CarouselItem.
  • 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.

API Reference

Prop

Type

Also accepts every prop of <section> (typed as <div> props), such as onMouseEnter and onFocus.

CarouselContent / CarouselItem

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.

CarouselPrevious / CarouselNext

A Button. Accepts every button prop; onPress, isDisabled and aria-label are set for you but can be overridden.

Prop

Type

useCarousel

Call inside Carousel to build custom controls. Throws outside one.

Prop

Type

CarouselApi

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.

  • Tabs: named panels the user picks directly.
  • Card: the usual slide surface.
  • Button: the base of the previous and next controls.
  • Pagination: paging through large result sets.