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.
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.Rootowns the Embla instance, the autoplay plugin, and context. Wraps everything.Carousel.Viewportis the clipped window. Applyoverflow: hiddenhere (Tailwind:overflow-hidden). The Embla ref attaches to this element.Carousel.Containeris the horizontal flex track that holds slides. Applydisplay: flexhere.Carousel.Slidewraps each item. Passindexso the slide can reflect its selected state viadata-state="active" | "inactive".Carousel.Previous/Carousel.Nextare real<button>elements wired to the Embla API. They auto-disable at edges (unlessoptions.loopis true).Carousel.Navigationwraps dot/number indicators. When passed no children, it renders oneCarousel.NavigationItemper 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.
<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.Containerbecomesflex-colinstead offlex(row direction doesn’t apply).Carousel.Viewportneeds 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].
<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.
<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.
<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.
<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.
<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.
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.
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.
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).
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.
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.
Exported types
import type {
CarouselApi, // EmblaApi instance
CarouselAutoplay, // boolean | AutoplayOptionsType
CarouselNavigationItemProps,
CarouselNavigationProps,
CarouselOptions, // EmblaOptionsType
CarouselPlugins, // EmblaPluginType[]
CarouselRootProps,
CarouselSlideProps,
} from "@togetheragency/ui/carousel";