Skip to Content

Carousel

A headless, composable carousel built on top of Embla Carousel . Ships drag-to-scroll, snap points, autoplay, looping, multi-slide layouts, and keyboard-accessible navigation; with every visual choice left to you.

1
2
3
4
5
import { Carousel } from "@togetheragency/ui/carousel"; function SimpleSlide({ index }: { index: number }) { return ( <div className="flex flex-col gap-4 overflow-hidden rounded-xl bg-card text-card-foreground ring-1 ring-foreground/10" > <div className="flex aspect-video items-center justify-center p-6" > <span className="text-3xl font-semibold">{index + 1}</span> </div> </div> ); } export function BasicCarousel() { return ( <Carousel.Root autoplay={false} options={{ align: "start" }}> <Carousel.Viewport className="overflow-hidden rounded-2xl"> <Carousel.Container className="flex"> {slides.map((slide, i) => ( <Carousel.Slide key={slide.title} index={i} className="min-w-0 flex-[0_0_100%] pr-3 last:pr-0" > <SimpleSlide index={i} /> </Carousel.Slide> ))} </Carousel.Container> </Carousel.Viewport> <div className="mt-4 flex items-center justify-between"> <Carousel.Navigation className="flex items-center gap-2"> {slides.map((slide, i) => ( <Carousel.NavigationItem key={slide.title} index={i} className="block h-2 w-2 rounded-full bg-neutral-300 transition-[width,background-color] duration-300 data-[state=active]:w-5 data-[state=active]:bg-foreground" /> ))} </Carousel.Navigation> <div className="flex gap-2"> <Carousel.Previous className="rounded-full border border-border px-4 py-2 text-sm font-medium disabled:opacity-40"> Prev </Carousel.Previous> <Carousel.Next className="rounded-full border border-border px-4 py-2 text-sm font-medium disabled:opacity-40"> Next </Carousel.Next> </div> </div> </Carousel.Root> ); }

Import

import { Carousel, useCarousel } from "@togetheragency/ui/carousel";

The component is exported as a compound object; every sub-component is a property of Carousel:

Carousel.Root; Carousel.Viewport; Carousel.Container; Carousel.Slide; Carousel.Previous; Carousel.Next; Carousel.Navigation; Carousel.NavigationItem;

The useCarousel hook returns the Embla API and scroll helpers from the nearest Carousel.Root and is intended for custom controls or telemetry.

Composition

Carousel follows a strict, composable structure. The minimum viable carousel is three nested parts; Root → Viewport → Container → Slide:

<Carousel.Root> <Carousel.Viewport> <Carousel.Container> <Carousel.Slide index={0}>…</Carousel.Slide> <Carousel.Slide index={1}>…</Carousel.Slide> <Carousel.Slide index={2}>…</Carousel.Slide> </Carousel.Container> </Carousel.Viewport> <Carousel.Previous>Prev</Carousel.Previous> <Carousel.Next>Next</Carousel.Next> <Carousel.Navigation /> </Carousel.Root>
  • Carousel.Root owns the Embla instance, the autoplay plugin, and context. Wraps everything.
  • Carousel.Viewport is the clipped window. Apply overflow: hidden here (Tailwind: overflow-hidden). The Embla ref attaches to this element.
  • Carousel.Container is the horizontal flex track that holds slides. Apply display: flex here.
  • Carousel.Slide wraps each item. Pass index so the slide can reflect its selected state via data-state="active" | "inactive".
  • Carousel.Previous / Carousel.Next are real <button> elements wired to the Embla API. They auto-disable at edges (unless options.loop is true).
  • Carousel.Navigation wraps dot/number indicators. When passed no children, it renders one Carousel.NavigationItem per scroll snap automatically. Pass children for full control.

The library is headless — no widths, gaps, or colors are applied for you. Slide widths come from your own utility classes (flex-[0_0_100%], flex-[0_0_33.333%], etc.), and the active state is exposed via data-state so you can style transitions with data-[state=active]:….

Usage

Basic usage

A standard carousel with one slide per view, dot navigation, and prev/next buttons. Buttons auto-disable when you reach an edge.

<Carousel.Root options={{ align: "start" }}> <Carousel.Viewport className="overflow-hidden"> <Carousel.Container className="flex"> {items.map((item, i) => ( <Carousel.Slide key={item.id} index={i} className="flex-[0_0_100%]"> {item.content} </Carousel.Slide> ))} </Carousel.Container> </Carousel.Viewport> <Carousel.Previous>Prev</Carousel.Previous> <Carousel.Next>Next</Carousel.Next> </Carousel.Root>

Autoplay

Autoplay is enabled by default. Pass an options object to tune the delay or the user-interaction behavior, or autoplay={false} to disable it entirely.

User interaction — drag, prev/next buttons, navigation items, or any call to the scrollPrev / scrollNext / scrollTo helpers from useCarousel — resets the autoplay countdown rather than stopping it. This is achieved by defaulting stopOnInteraction to false and calling the plugin’s reset() on interaction. Pass autoplay={{ stopOnInteraction: true }} if you want the older “stop on first interaction” behavior.

1
2
3
4
5
<Carousel.Root options={{ loop: true, align: "start" }} autoplay={{ delay: 2500 }} > <Carousel.Viewport className="overflow-hidden rounded-2xl"> <Carousel.Container className="flex"> {slides.map((slide, i) => ( <Carousel.Slide key={slide.title} index={i} className="flex-[0_0_100%]"> <SimpleSlide index={i} /> </Carousel.Slide> ))} </Carousel.Container> </Carousel.Viewport> <Carousel.Navigation className="mt-4 flex justify-center gap-2"> {slides.map((slide, i) => ( <Carousel.NavigationItem key={slide.title} index={i} className="h-2 w-2 rounded-full bg-neutral-300 data-[state=active]:w-5 data-[state=active]:bg-foreground" /> ))} </Carousel.Navigation> </Carousel.Root>

Looping

Pass options={{ loop: true }} to wrap in both directions. With looping enabled, Carousel.Previous and Carousel.Next never disable.

Wheel gestures

Horizontal trackpad swipes and horizontal mouse-wheel events over the viewport advance the carousel by one snap at a time. Only horizontal-dominant deltas are consumed — vertical scrolling passes straight through to the page, so the carousel never blocks scrolling past it. Pass wheelGestures={false} to disable.

<Carousel.Root wheelGestures={false}>…</Carousel.Root>

Each consumed gesture moves the carousel by one snap, with a short cooldown between moves so a single inertial trackpad swipe doesn’t skip multiple slides. Only horizontal deltas (deltaX) are consumed; vertical carousels are not affected by this feature.

Multi-slide layouts

Slide width is just CSS — set flex-[0_0_33.333%] (or any other basis) on Carousel.Slide to show multiple slides per view. Pair with options={{ dragFree: true }} for momentum scrolling that doesn’t snap.

Responsive slide counts

Because slide width is just CSS, you can change how many slides are visible per breakpoint by composing Tailwind’s basis-* utilities. Pair with shrink-0 grow-0 so the flex item honors the basis exactly — embla expects slides to be fixed-width children, not flex-grow items.

<Carousel.Slide index={i} className="min-w-0 shrink-0 grow-0 basis-full pr-3 md:basis-1/2 lg:basis-1/3" > </Carousel.Slide>

Vertical orientation

Pass options={{ axis: "y" }} for a vertical carousel. Two CSS adjustments are required on the consumer side:

  • Carousel.Container becomes flex-col instead of flex (row direction doesn’t apply).
  • Carousel.Viewport needs a defined height (e.g. h-72) since the parent no longer constrains the slides’ axis.

Carousel.Previous / Carousel.Next continue to work — they call scrollPrev / scrollNext, which embla maps to the configured axis.

<Carousel.Root options={{ axis: "y" }}> <Carousel.Viewport className="h-72 overflow-hidden rounded-2xl"> <Carousel.Container className="flex h-full flex-col"> {slides.map((slide, i) => ( <Carousel.Slide key={slide.title} index={i} className="min-h-0 flex-[0_0_100%]" > </Carousel.Slide> ))} </Carousel.Container> </Carousel.Viewport> </Carousel.Root>

Custom navigation

Carousel.Navigation renders default items when given no children, but you can pass your own — useful when you want labels, thumbnails, or a non-1:1 mapping between snaps and indicators.

<Carousel.Navigation aria-label="Featured"> <Carousel.NavigationItem index={0}>Intro</Carousel.NavigationItem> <Carousel.NavigationItem index={1}>Features</Carousel.NavigationItem> <Carousel.NavigationItem index={2}>Pricing</Carousel.NavigationItem> </Carousel.Navigation>

Reading the Embla API

Use the useCarousel hook (from inside Carousel.Root) or the onApiChange prop to access the underlying Embla instance — useful for analytics, custom controls, or wiring up plugins.

<Carousel.Root onApiChange={(api) => { api?.on("select", () => { console.log("selected:", api.selectedScrollSnap()); }); }} > </Carousel.Root>

Examples

Looping with peek

A loop: true carousel with align: "center" and slides narrower than the viewport — inactive slides get scaled and faded via data-[state=inactive].

1
2
3
4
5
<Carousel.Root options={{ loop: true, align: "center" }}> <Carousel.Viewport className="overflow-hidden rounded-2xl"> <Carousel.Container className="flex"> {slides.map((slide, i) => ( <Carousel.Slide key={slide.title} index={i} className="group min-w-0 flex-[0_0_70%] pr-3" > <div className="transition-all duration-500 group-data-[state=inactive]:scale-95 group-data-[state=inactive]:opacity-50"> <SimpleSlide index={i} /> </div> </Carousel.Slide> ))} </Carousel.Container> </Carousel.Viewport> <div className="mt-4 flex justify-center gap-2"> <Carousel.Previous className="grid size-9 place-items-center rounded-full bg-foreground text-background"> </Carousel.Previous> <Carousel.Next className="grid size-9 place-items-center rounded-full bg-foreground text-background"> </Carousel.Next> </div> </Carousel.Root>

Multi-slide, drag-free

Three slides per view with momentum scrolling instead of snapping.

1
2
3
4
5
6
7
8
9
10
<Carousel.Root options={{ dragFree: true, align: "start" }}> <Carousel.Viewport className="overflow-hidden"> <Carousel.Container className="flex"> {items.map((item, i) => ( <Carousel.Slide key={item.id} index={i} className="min-w-0 flex-[0_0_33.333%] pr-3" > <SimpleSlide index={i} /> </Carousel.Slide> ))} </Carousel.Container> </Carousel.Viewport> </Carousel.Root>

Numbered navigation

Carousel.Navigation accepts children for custom indicators — here, numbered pills with active state.

1
2
3
4
5
<Carousel.Root options={{ loop: true }}> <Carousel.Viewport className="overflow-hidden rounded-2xl"> <Carousel.Container className="flex"> {slides.map((slide, i) => ( <Carousel.Slide key={slide.title} index={i} className="flex-[0_0_100%]"> <SimpleSlide index={i} /> </Carousel.Slide> ))} </Carousel.Container> </Carousel.Viewport> <Carousel.Navigation className="mt-4 flex items-center justify-center gap-2"> {slides.map((slide, i) => ( <Carousel.NavigationItem key={slide.title} index={i} aria-label={`Go to ${slide.title}`} className="grid size-8 place-items-center rounded-full border border-border text-xs font-medium data-[state=active]:border-foreground data-[state=active]:bg-foreground data-[state=active]:text-background" > {i + 1} </Carousel.NavigationItem> ))} </Carousel.Navigation> </Carousel.Root>

Vertical orientation

A vertical carousel — axis: "y" plus a fixed-height viewport and a flex-col container.

1
2
3
4
5
<Carousel.Root options={{ axis: "y", align: "start" }}> <Carousel.Viewport className="h-72 overflow-hidden rounded-2xl"> <Carousel.Container className="flex h-full flex-col"> {slides.map((slide, i) => ( <Carousel.Slide key={slide.title} index={i} className="min-h-0 flex-[0_0_100%]" > <div className="flex h-full items-center justify-center rounded-xl bg-card text-card-foreground ring-1 ring-foreground/10"> <span className="text-3xl font-semibold">{i + 1}</span> </div> </Carousel.Slide> ))} </Carousel.Container> </Carousel.Viewport> <div className="mt-4 flex items-center justify-center gap-2"> <Carousel.Previous className="grid size-9 place-items-center rounded-full border border-border"> </Carousel.Previous> <Carousel.Next className="grid size-9 place-items-center rounded-full border border-border"> </Carousel.Next> </div> </Carousel.Root>

Responsive slide counts

One slide per view on mobile, two on md, three on lg — all from one className on Carousel.Slide. No JS breakpoint logic required.

1
2
3
4
5
6
7
8
9
10
<Carousel.Root options={{ align: "start" }}> <Carousel.Viewport className="overflow-hidden"> <Carousel.Container className="flex"> {items.map((item, i) => ( <Carousel.Slide key={item.id} index={i} className="min-w-0 shrink-0 grow-0 basis-full pr-3 md:basis-1/2 lg:basis-1/3" > <SimpleSlide index={i} /> </Carousel.Slide> ))} </Carousel.Container> </Carousel.Viewport> </Carousel.Root>

API Reference

Carousel.Root

Renders a <div>. Owns the Embla instance and provides context to every sub-component. Extends all native div props.

PropDefaultDescription
optionsundefinedEmbla options  (e.g. loop, align, dragFree, axis, slidesToScroll).
pluginsundefinedEmbla plugins . If you supply your own Autoplay() here, the autoplay prop is ignored.
autoplaytrueConfigures the built-in autoplay plugin. Pass false to disable, or an options object to override delays and behavior. Defaults stopOnInteraction to false so interaction resets the timer rather than halting autoplay.
wheelGesturestrueEnables horizontal wheel/trackpad gestures on the viewport. Only horizontal-dominant deltas advance the carousel; vertical scrolling passes through. Pass false to disable.
onApiChangeundefinedFired whenever the Embla API reference changes. Useful for wiring up custom listeners.

Data attributes: data-slot="carousel-root".

Carousel.Viewport

The clipped window — attach overflow-hidden here. Renders a <div> and extends all native div props.

Data attributes: data-slot="carousel-viewport".

Carousel.Container

The horizontal flex track that holds slides. Apply display: flex here. Renders a <div> and extends all native div props.

Data attributes: data-slot="carousel-container".

Carousel.Slide

A single slide. Renders a <div role="group" aria-roledescription="slide"> and extends all native div props.

PropTypeDefaultDescription
indexnumberundefinedThe slide’s index. Required for data-state to reflect active/inactive. The DOM updates without re-rendering the slide.

Data attributes: data-slot="carousel-slide", data-state="active" | "inactive".

Carousel.Previous / Next

Real <button> elements wired to scrollPrev / scrollNext. They automatically set disabled when the carousel can’t scroll further in that direction (unless options.loop is true). Override by passing disabled yourself or by calling event.preventDefault() in your onClick.

PropTypeDefaultDescription
type"button" | "submit" | "reset""button"Standard button type.
onClickReact.MouseEventHandler<HTMLButtonElement>undefinedFires before the scroll. Call event.preventDefault() to skip the built-in scroll action.
disabledbooleanderived from canScrollPrev/canScrollNextForce-disables the button.

Built-in attributes: aria-label="Previous slide" / "Next slide", data-slot="carousel-previous" | "carousel-next", data-disabled="true" | "false".

Carousel.Navigation

Wraps navigation items. Renders a <nav> and extends all native nav props (except children).

PropTypeDefaultDescription
childrenReact.ReactNodeone item per scroll snapWhen omitted, one Carousel.NavigationItem is rendered per snap. Pass children for full control over item rendering.
aria-labelstring"Carousel navigation"Accessible label for the nav landmark.

Data attributes: data-slot="carousel-navigation".

Carousel.NavigationItem

A <button> that jumps to a specific slide index. Reflects whether it represents the currently-selected snap via aria-current and data-state.

PropTypeDefaultDescription
indexnumberrequiredThe slide index this item jumps to.
type"button" | "submit" | "reset""button"Standard button type.
onClickReact.MouseEventHandlerundefinedFires before the scroll. Call event.preventDefault() to skip the built-in jump.
aria-labelstring"Go to slide N"Defaults to a 1-indexed label; override for custom item labels.

Data attributes: data-slot="carousel-navigation-item", data-state="active" | "inactive". Also sets aria-current="true" when selected.

useCarousel()

Returns the Embla context from the nearest Carousel.Root. Throws if used outside one.

FieldTypeDescription
emblaRef(node: HTMLElement | null) => voidThe ref to attach to the viewport. Already wired by Carousel.Viewport.
emblaApiCarouselApi | undefinedThe Embla API. undefined until the carousel mounts.
scrollPrev() => voidScroll one snap backwards.
scrollNext() => voidScroll one snap forwards.
scrollTo(index: number) => voidJump to a specific snap index.

Exported types

import type { CarouselApi, // EmblaApi instance CarouselAutoplay, // boolean | AutoplayOptionsType CarouselNavigationItemProps, CarouselNavigationProps, CarouselOptions, // EmblaOptionsType CarouselPlugins, // EmblaPluginType[] CarouselRootProps, CarouselSlideProps, } from "@togetheragency/ui/carousel";
Last updated on