Skip to content

Carousel

A horizontal scroller of slides with previous and next controls.

Category
Content
Adoption
Not used yet
import { Carousel } from "@oration/canon/components/carousel";
packages/canon/src/components/carousel.tsx
PR-0412Approved
Friday, Oct 2212 invoices
$1,284,650.20
PR-0413Needs approval
Friday, Oct 9148 invoices
$902,118.45
PR-0414Draft
Friday, Oct 1696 invoices
$611,940.00
PR-0415Draft
Friday, Oct 2341 invoices
$238,402.75
import { Button } from "@oration/canon/components/button";import {  Carousel,  CarouselContent,  CarouselItem,  CarouselNext,  CarouselPrevious,} from "@oration/canon/components/carousel";import { StatusLabel } from "@oration/canon/components/status-dot";import { toast } from "@oration/canon/components/toast";export function Hero() {    const runs = [        {            id: "PR-0412",            date: "Friday, Oct 2",            invoices: 212,            total: "$1,284,650.20",            status: "Approved",        },        {            id: "PR-0413",            date: "Friday, Oct 9",            invoices: 148,            total: "$902,118.45",            status: "Needs approval",        },        {            id: "PR-0414",            date: "Friday, Oct 16",            invoices: 96,            total: "$611,940.00",            status: "Draft",        },        {            id: "PR-0415",            date: "Friday, Oct 23",            invoices: 41,            total: "$238,402.75",            status: "Draft",        },    ];    return (        <Carousel            aria-label="Upcoming payment runs"            opts={{ align: "start" }}            className="mx-12 w-[calc(100%-6rem)] max-w-md"        >            <CarouselContent>                {runs.map((run, i) => (                    <CarouselItem                        key={run.id}                        aria-label={`${i + 1} of ${runs.length}`}                        className="basis-4/5"                    >                        <div className="flex flex-col gap-3 rounded-xl bg-card p-4 shadow-border">                            <div className="flex items-center justify-between">                                <span className="font-mono text-xs text-muted-foreground">                                    {run.id}                                </span>                                <StatusLabel                                    tone={                                        run.status === "Approved"                                            ? "success"                                            : run.status === "Needs approval"                                              ? "warning"                                              : "neutral"                                    }                                >                                    {run.status}                                </StatusLabel>                            </div>                            <div className="flex flex-col gap-0.5">                                <span className="text-sm font-semibold">                                    {run.date}                                </span>                                <span className="text-13 text-muted-foreground tabular-nums">                                    {run.invoices} invoices                                </span>                            </div>                            <span className="text-xl font-medium tabular-nums">                                {run.total}                            </span>                            <Button                                type="button"                                variant="outline"                                size="sm"                                className="self-start"                                onClick={() =>                                    toast.add({ title: `Opened ${run.id}` })                                }                            >                                Review run                            </Button>                        </div>                    </CarouselItem>                ))}            </CarouselContent>            <CarouselPrevious />            <CarouselNext />        </Carousel>    );}

Usage#

Carousel is a scroller of slides with Previous and Next buttons, built on Embla. It drags with a pointer or a finger, snaps to each slide and moves with the arrow keys when focus is inside. It suits a short, optional set of things of the same kind: upcoming payment runs, a tour of a new feature, sample remittance templates. Nothing in the product uses it yet. The common mistake is hiding content people need inside one: whatever is on slide four is effectively invisible, so anything people must see or compare belongs in a list or a grid.

When to use

  • For a short, optional set of same-shaped cards in a narrow space, such as the next four payment runs on an overview.
  • For images or previews people flick through, such as remittance email templates.
  • When more than one card fits per view and the rest can scroll in, with part of the next card showing.

When not to use

  • For items people compare or act on. Show them all in a list or table. Use Item
  • For pages read in order in a modal, such as what's new. Use Stacked dialog
  • For a pile that says there are more without showing them. Use Card stack
  • For switching between views of the same content. Use Tabs

The Hairline-and-Lift Rule

Slides that are cards use Card White with the hairline lift. The carousel itself draws no surface.

No autoplay

Slides move only when someone moves them. Embla's autoplay plugin isn't installed, and motion that starts on its own breaks the reduced-motion story.

Anatomy#

PR-0412$1,284,650.20
PR-0413$902,118.45
  1. Viewport. CarouselContent: an overflow-hidden box around a flex track with a -16px leading margin.
  2. Slide. CarouselItem: 16px leading padding (the gutter) and basis-full. Pass basis-1/2 or basis-1/3 for more per view.
  3. Next slide. Clipped by the viewport. Showing part of it tells people there's more.
  4. Previous. CarouselPrevious: a 28px round outline icon button 48px outside the leading edge, named Previous slide.
  5. Next. CarouselNext: the same, 48px outside the trailing edge, named Next slide.

Examples#

Several per view

basis-1/2 sm:basis-1/3 on each item fits more per view, and slidesToScroll: "auto" pages by a full view. align: "start" keeps the first slide flush with the edge.

import {  Carousel,  CarouselContent,  CarouselItem,  CarouselNext,  CarouselPrevious,} from "@oration/canon/components/carousel";import { toast } from "@oration/canon/components/toast";import { MailIcon } from "lucide-react";export function SeveralPerView() {    const templates = [        {            id: "standard",            name: "Standard remittance",            detail: "Invoice list and totals",        },        { id: "short", name: "Short notice", detail: "One line per payment" },        { id: "itemized", name: "Itemized", detail: "Line items and credits" },        { id: "spanish", name: "Spanish", detail: "Aviso de pago" },        { id: "plain", name: "Plain text", detail: "For strict mail filters" },    ];    return (        <Carousel            aria-label="Remittance email templates"            opts={{ align: "start", slidesToScroll: "auto" }}            className="mx-12 w-[calc(100%-6rem)] max-w-xl"        >            <CarouselContent>                {templates.map((template, i) => (                    <CarouselItem                        key={template.id}                        aria-label={`${template.name}, ${i + 1} of ${templates.length}`}                        className="basis-1/2 sm:basis-1/3"                    >                        <button                            type="button"                            onClick={() =>                                toast.add({                                    title: `${template.name} selected`,                                    description:                                        "Used for remittances from the next run.",                                })                            }                            className="flex h-28 w-full flex-col justify-between rounded-xl bg-card p-3 text-left shadow-border outline-none transition-shadow duration-150 ease-out hover:shadow-border-hover focus-visible:ring-3 focus-visible:ring-ring/40"                        >                            <MailIcon                                aria-hidden="true"                                className="size-4 text-muted-foreground"                            />                            <span className="flex flex-col gap-0.5">                                <span className="text-13 font-medium">                                    {template.name}                                </span>                                <span className="text-xs text-muted-foreground">                                    {template.detail}                                </span>                            </span>                        </button>                    </CarouselItem>                ))}            </CarouselContent>            <CarouselPrevious />            <CarouselNext />        </Carousel>    );}

Dots and a counter

setApi hands you Embla. Read selectedScrollSnap() on select, jump with scrollTo(index), and announce the position with a polite live region.

Hold an invoice to keep it out of every run until someone releases it.
Split a payment to pay part now and the rest in a later run.
Invoices on 2/10 net 30 terms are flagged when paying early saves money.
Approvers can release runs up to their limit from Slack.

import {  Carousel,  type CarouselApi,  CarouselContent,  CarouselItem,  CarouselNext,  CarouselPrevious,} from "@oration/canon/components/carousel";import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function WithCounter() {    const [api, setApi] = React.useState<CarouselApi>();    const [current, setCurrent] = React.useState(0);    const [count, setCount] = React.useState(0);    const tips = [        "Hold an invoice to keep it out of every run until someone releases it.",        "Split a payment to pay part now and the rest in a later run.",        "Invoices on 2/10 net 30 terms are flagged when paying early saves money.",        "Approvers can release runs up to their limit from Slack.",    ];    React.useEffect(() => {        if (!api) return;        const update = () => {            setCount(api.scrollSnapList().length);            setCurrent(api.selectedScrollSnap());        };        update();        api.on("select", update);        api.on("reInit", update);        return () => {            api.off("select", update);            api.off("reInit", update);        };    }, [api]);    return (        <div className="mx-12 flex w-[calc(100%-6rem)] max-w-sm flex-col gap-3">            <Carousel aria-label="Payment run tips" setApi={setApi}>                <CarouselContent>                    {tips.map((tip, i) => (                        <CarouselItem                            key={tip}                            aria-label={`${i + 1} of ${tips.length}`}                        >                            <div className="flex h-28 items-center rounded-xl bg-card p-4 text-sm text-pretty shadow-border">                                {tip}                            </div>                        </CarouselItem>                    ))}                </CarouselContent>                <CarouselPrevious />                <CarouselNext />            </Carousel>            <div className="flex items-center justify-center gap-1.5">                {tips.map((tip, i) => (                    <button                        key={tip}                        type="button"                        aria-label={`Go to tip ${i + 1}`}                        aria-current={i === current || undefined}                        onClick={() => api?.scrollTo(i)}                        className="flex size-6 items-center justify-center rounded-full outline-none focus-visible:ring-3 focus-visible:ring-ring/40"                    >                        <span                            className={cn(                                "h-1.5 rounded-full transition-[width,background-color] duration-200 ease-out motion-reduce:transition-none",                                i === current                                    ? "w-4 bg-foreground"                                    : "w-1.5 bg-foreground/20",                            )}                        />                    </button>                ))}            </div>            <p                aria-live="polite"                className="text-center text-xs text-muted-foreground tabular-nums"            >                {count ? `${current + 1} of ${count}` : null}            </p>        </div>    );}

Vertical

orientation="vertical" needs a fixed height on CarouselContent. The buttons move above and below.

Priya RamanApproved PR-0412 with 212 invoices.
Tomás FerreiraHeld INV-20944 until the credit memo arrives.
Aisha BelloRequested a new W-9 from Halcyon.
Jordan LeeChanged Orchard Street to Net 45.
import {  Carousel,  CarouselContent,  CarouselItem,  CarouselNext,  CarouselPrevious,} from "@oration/canon/components/carousel";export function Vertical() {    const notes = [        { who: "Priya Raman", text: "Approved PR-0412 with 212 invoices." },        {            who: "Tomás Ferreira",            text: "Held INV-20944 until the credit memo arrives.",        },        { who: "Aisha Bello", text: "Requested a new W-9 from Halcyon." },        { who: "Jordan Lee", text: "Changed Orchard Street to Net 45." },    ];    return (        <Carousel            aria-label="Recent approvals activity"            orientation="vertical"            opts={{ align: "start" }}            className="my-12 w-full max-w-xs"        >            <CarouselContent className="h-40">                {notes.map((note, i) => (                    <CarouselItem                        key={note.who}                        aria-label={`${i + 1} of ${notes.length}`}                        className="basis-1/2"                    >                        <div className="flex h-full flex-col justify-center gap-0.5 rounded-xl bg-card px-4 shadow-border">                            <span className="text-13 font-medium">                                {note.who}                            </span>                            <span className="text-13 text-muted-foreground">                                {note.text}                            </span>                        </div>                    </CarouselItem>                ))}            </CarouselContent>            <CarouselPrevious />            <CarouselNext />        </Carousel>    );}

Looping

opts={{ loop: true }} wraps from the last slide to the first, so neither button ever disables.

Northwind Freight2 open invoices
Halcyon3 open invoices
Orchard Street4 open invoices
import {  Carousel,  CarouselContent,  CarouselItem,  CarouselNext,  CarouselPrevious,} from "@oration/canon/components/carousel";export function Loop() {    const suppliers = ["Northwind Freight", "Halcyon", "Orchard Street"];    return (        <Carousel            aria-label="Suppliers with open invoices"            opts={{ loop: true }}            className="mx-12 w-[calc(100%-6rem)] max-w-xs"        >            <CarouselContent>                {suppliers.map((supplier, i) => (                    <CarouselItem                        key={supplier}                        aria-label={`${i + 1} of ${suppliers.length}`}                    >                        <div className="flex h-24 flex-col justify-center gap-0.5 rounded-xl bg-card px-4 shadow-border">                            <span className="text-sm font-medium">                                {supplier}                            </span>                            <span className="text-13 text-muted-foreground tabular-nums">                                {i + 2} open invoices                            </span>                        </div>                    </CarouselItem>                ))}            </CarouselContent>            <CarouselPrevious />            <CarouselNext />        </Carousel>    );}

States#

States
StateTreatment
At the startPrevious is disabled at 50% opacity, unless loop is set.
In the middleBoth buttons are enabled.
At the endNext is disabled.
DraggingThe track follows the pointer and snaps to the nearest slide on release, with Embla's own easing.
Button hover and focusThe outline button states: Well Gray on hover, the indigo ring on keyboard focus.

Behavior#

  • Drag with a mouse, pen or finger; the buttons scroll by one slide, or by a group of slides with opts={{ slidesToScroll: 'auto' }}.
  • The left and right arrow keys move to the previous and next slide when focus is anywhere inside the carousel. That includes inputs inside slides, where the arrow keys stop moving the caret.
  • opts goes straight to Embla: align ("start", "center" or "end"), loop, slidesToScroll, dragFree and the rest. axis is set from orientation.
  • setApi hands you the Embla API, for a counter (api.selectedScrollSnap(), api.scrollSnapList()) or for jumping with api.scrollTo(index). Listen to select and clean up with off.
  • orientation="vertical" stacks the track; give CarouselContent a fixed height. The buttons move above and below and rotate 90 degrees.
  • The buttons sit outside the carousel's box. Leave 48px on each side (mx-12) or move them in with className.
  • Inside the carousel, call useCarousel() to build custom controls from scrollPrev, scrollNext, canScrollPrev and canScrollNext.

Do and don't#

PR-0412$1,284,650.20
PR-0413$902,118.45
1 of 4
Do. Show part of the next slide and a position such as 2 of 5, so people know there's more and where they are.
PR-0412$1,284,650.20
PR-0413$902,118.45
Don't. Show exactly one full-width slide with nothing to say more exist. Most people never press Next.
Do. Name the carousel with aria-label, such as Upcoming payment runs.
Don't. Leave the region unnamed. Screen readers announce an anonymous carousel.
Do. Put anything people must see or compare in a list or grid.
Don't. Hide a failed payment run on slide four of a carousel on the overview.

Content#

  • Name the set in a heading above it and in aria-label: Upcoming payment runs.
  • Keep slides parallel: the same fields in the same order, so flicking compares like with like.
  • A counter reads 2 of 5, in tabular figures.

Accessibility#

  • The root is role="region" with aria-roledescription="carousel"; give it an aria-label, because a region needs a name.
  • Each slide is role="group" with aria-roledescription="slide". Add aria-label="2 of 5" or the slide's title to each one.
  • Previous and Next are buttons with hidden names, Previous slide and Next slide, and are disabled at the ends.
  • Slide changes aren't announced. Add a counter with aria-live="polite" if position matters.
  • Embla doesn't animate with CSS, so reduced-motion settings don't shorten its scroll. Keep jumps short.
Keyboard interactions
KeysAction
←Previous slide, when focus is inside.
→Next slide, when focus is inside.
TabMoves through slide content and the buttons.
EnterActivates the focused button.

Design tokens#

Design tokens
TokenUsed for
--borderButton stroke (light)
--mutedButton hover fill
--ringButton focus ring
rounded-fullRound buttons

API reference#

CarouselContent

The clipping viewport and the flex track. className goes on the track.

Other props spread onto <div>.

No props of its own.

CarouselItem

One slide. Set basis-* for more per view.

Other props spread onto <div>.

No props of its own.

CarouselPrevious

Scrolls back. Disabled at the start.

Other props spread onto Button.

Props of CarouselPrevious
PropTypeDefaultDescription
variant"default" | "outline" | "secondary" | "ghost" | "destructive" | "link""outline"Button variant.
size"xs" | "sm" | "default" | "lg" | "icon-xs" | "icon-sm" | "icon" | "icon-lg""icon-sm"Button size.

CarouselNext

Scrolls forward. Disabled at the end.

Other props spread onto Button.

Props of CarouselNext
PropTypeDefaultDescription
variant"default" | "outline" | "secondary" | "ghost" | "destructive" | "link""outline"Button variant.
size"xs" | "sm" | "default" | "lg" | "icon-xs" | "icon-sm" | "icon" | "icon-lg""icon-sm"Button size.

useCarousel

Inside a Carousel, returns { carouselRef, api, opts, orientation, scrollPrev, scrollNext, canScrollPrev, canScrollNext }. Throws outside one.

No props of its own.

CarouselApi

The Embla API type, for setApi.

No props of its own.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

No file in apps/web imports it. Hence experimental.

The buttons are rounded-full. DESIGN.md gives buttons 10px corners and keeps round for dots, avatars, meters and switches.

Previous and Next are icon-only with no tooltip, unlike other icon-only buttons in the suite.

The buttons sit 48px outside the carousel's box, so at narrow widths they fall off-screen unless the layout reserves the space.

The arrow-key handler runs in the capture phase on the whole region and always prevents the default, so the left and right arrows don't move the caret in inputs inside a slide. In vertical carousels, up and down do nothing.

The region has no accessible name by default, slides aren't labelled with their position, and slide changes aren't announced.