Carousel
A horizontal scroller of slides with previous and next controls.
- Status
- Experimental
- Level
- Organism
- Category
- Content
- Adoption
- Not used yet
import { Carousel } from "@oration/canon/components/carousel";packages/canon/src/components/carousel.tsximport { 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
No autoplay
Anatomy#
- Viewport.
CarouselContent: anoverflow-hiddenbox around a flex track with a -16px leading margin. - Slide.
CarouselItem: 16px leading padding (the gutter) andbasis-full. Passbasis-1/2orbasis-1/3for more per view. - Next slide. Clipped by the viewport. Showing part of it tells people there's more.
- Previous.
CarouselPrevious: a 28px round outline icon button 48px outside the leading edge, named Previous slide. - 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.
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.
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.
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#
| State | Treatment |
|---|---|
| At the start | Previous is disabled at 50% opacity, unless loop is set. |
| In the middle | Both buttons are enabled. |
| At the end | Next is disabled. |
| Dragging | The track follows the pointer and snaps to the nearest slide on release, with Embla's own easing. |
| Button hover and focus | The 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.
optsgoes straight to Embla:align("start","center"or"end"),loop,slidesToScroll,dragFreeand the rest.axisis set fromorientation.setApihands you the Embla API, for a counter (api.selectedScrollSnap(),api.scrollSnapList()) or for jumping withapi.scrollTo(index). Listen toselectand clean up withoff.orientation="vertical"stacks the track; giveCarouselContenta 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 withclassName. - Inside the carousel, call
useCarousel()to build custom controls fromscrollPrev,scrollNext,canScrollPrevandcanScrollNext.
Do and don't#
aria-label, such as Upcoming payment runs.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"witharia-roledescription="carousel"; give it anaria-label, because a region needs a name. - Each slide is
role="group"witharia-roledescription="slide". Addaria-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.
| Keys | Action |
|---|---|
| ← | Previous slide, when focus is inside. |
| → | Next slide, when focus is inside. |
| Tab | Moves through slide content and the buttons. |
| Enter | Activates the focused button. |
Design tokens#
| Token | Used for |
|---|---|
--border | Button stroke (light) |
--muted | Button hover fill |
--ring | Button focus ring |
rounded-full | Round buttons |
API reference#
Carousel
The root and Embla instance.
Other props spread onto <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
opts | EmblaOptionsType | No default | Embla options: align, loop, slidesToScroll, dragFree… |
plugins | EmblaPluginType[] | No default | Embla plugins. None are installed in the repo. |
orientation | "horizontal" | "vertical" | "horizontal" | Scroll axis. |
setApi | (api: CarouselApi) => void | No default | Receives the Embla API once it's ready. |
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.
| Prop | Type | Default | Description |
|---|---|---|---|
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.
| Prop | Type | Default | Description |
|---|---|---|---|
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.