Carousel
Slides on Embla, with previous and next buttons and keyboard arrows.
shadcn's base-nova carousel on Embla, with four changes. The root is a region described as a carousel and named "Slides" until you name it — aria-label, or aria-labelledby at a visible heading — and region={false} hands the region to an ancestor that is one, a section or a card named by its title. The arrow keys follow the axis: Left and Right move a row, Up and Down a column. A row follows the reading direction too: inside a right-to-left page (the nearest dir, else the computed direction) Embla is handed direction "rtl" unless opts.direction says otherwise, Left moves on and Right goes back, the previous button hangs off the row's start — the right — and the next off its end, their chevrons turn to point the way they move, and the gutters sit on the start side (ms/ps rather than ml/pl), so a page flips without a class of its own. Embla moves the track from script, where the reduced-motion media query never reaches, so the buttons and the keys jump rather than slide for a reader who asked for less motion (the OS setting or a [data-motion="reduced"] above); call api.scrollTo(index, prefersReducedMotion(element)) for controls of your own. Each item is a group described as a slide — name it "2 of 5". A slide that takes the focus from Tab scrolls into view (Embla's watchFocus). The previous and next controls are Vibra's outline icon buttons; name them for what they move when a press moves more than one slide.
Install
npx shadcn@latest add @vibra/carouselNeeds the @vibra registry in your components.json — set it up once.
Examples
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@/components/ui/carousel"
const RELEASES = [
{ version: "2.14", title: "Saved views", body: "Pin a filtered table and share it with a link." },
{ version: "2.13", title: "Usage alerts", body: "An email when a workspace passes 80% of its plan." },
{ version: "2.12", title: "CSV import", body: "Bring customers in from a spreadsheet, with a dry run first." },
{ version: "2.11", title: "Audit log export", body: "A month of audit events, downloaded as JSON." },
]
export default function CarouselDemo() {
return (
// The arrows sit outside the slides, so the padding keeps them on the stage.
<div className="w-full max-w-sm px-12">
<Carousel aria-label="What's new" opts={{ align: "start" }}>
<CarouselContent>
{RELEASES.map((release, index) => (
<CarouselItem key={release.version} aria-label={`${index + 1} of ${RELEASES.length}`}>
<div className="flex h-36 flex-col justify-between rounded-xl bg-surface p-4 ring-1 ring-border ring-inset">
<span className="font-mono text-xs text-muted-foreground">v{release.version}</span>
<div className="flex flex-col gap-1">
<p className="text-sm font-semibold">{release.title}</p>
<p className="text-sm text-muted-foreground">{release.body}</p>
</div>
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
</div>
)
}Several at a time
Three to a view, two on a tablet, one and a peek on a phone; a press moves a whole view along.
import { formatCurrency } from "@/lib/format"
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@/components/ui/carousel"
import { ProductArt } from "@/components/ui/product-art"
const PRODUCTS = [
["Standing Desk", "Office", 549],
["Ergonomic Office Chair", "Office", 179],
["Mechanical Keyboard", "Electronics", 89],
["Webcam HD", "Electronics", 59],
["Laptop Stand", "Electronics", 34],
["Desk Lamp LED", "Office", 29],
["Bookshelf 5-Tier", "Furniture", 149],
] as const
// Three to a view from lg, two from sm, and on a phone one with the next one
// peeking in, which says there is more before anyone swipes. A press moves a
// whole view along (slidesToScroll: "auto"), so nothing is skipped or shown
// twice, and the buttons are named for what they move.
export default function CarouselSeveral() {
return (
<div className="w-full max-w-2xl px-12">
<Carousel aria-label="Home office picks" opts={{ align: "start", slidesToScroll: "auto" }}>
<CarouselContent>
{PRODUCTS.map(([name, category, price], index) => (
<CarouselItem key={name} aria-label={`${index + 1} of ${PRODUCTS.length}`} className="basis-4/5 sm:basis-1/2 lg:basis-1/3">
<ProductArt name={name} category={category} />
<p className="mt-2 text-sm font-medium">{name}</p>
<p className="text-sm text-muted-foreground tabular-nums">{formatCurrency(price, "USD", { maximumFractionDigits: 0 })}</p>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious aria-label="Previous products" />
<CarouselNext aria-label="Next products" />
</Carousel>
</div>
)
}Dots and a count
Each dot a named button, the current one marked and drawn longer, and the count in a polite live line.
"use client"
import * as React from "react"
import { CreditCardIcon, TargetIcon, UploadIcon, UsersIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { prefersReducedMotion } from "@/hooks/use-reduced-motion"
import { Carousel, CarouselContent, CarouselItem, type CarouselApi } from "@/components/ui/carousel"
const TIPS = [
{ icon: UsersIcon, title: "Invite your team", body: "The Team plan seats 30 people for $49 a month. An invite lasts seven days." },
{ icon: CreditCardIcon, title: "Add a payment method", body: "Invoices go out on the 1st; without a card on file the first is sent for manual payment." },
{ icon: UploadIcon, title: "Import your customers", body: "A CSV with a name and an email is enough. A dry run shows what would change first." },
{ icon: TargetIcon, title: "Set a monthly goal", body: "Pick a figure to chase, and the overview charts the month against it." },
]
// A dot is a button named for the tip it shows, and the one on show says so
// (aria-current) and is drawn longer, so it is never told apart by colour
// alone. The count under the dots is a polite live line.
export default function CarouselDots() {
const [api, setApi] = React.useState<CarouselApi>()
const [current, setCurrent] = React.useState(0)
React.useEffect(() => {
if (!api) return
const sync = () => setCurrent(api.selectedScrollSnap())
sync()
api.on("select", sync)
return () => {
api.off("select", sync)
}
}, [api])
return (
<div className="flex w-full max-w-sm flex-col items-center gap-3">
<Carousel aria-label="Getting started" setApi={setApi} className="w-full">
<CarouselContent>
{TIPS.map(({ icon: Icon, title, body }, index) => (
<CarouselItem key={title} aria-label={`${index + 1} of ${TIPS.length}`}>
<div className="flex h-40 flex-col gap-3 panel p-5 ring-inset">
<Icon aria-hidden="true" className="size-5 text-muted-foreground" />
<p className="font-medium">{title}</p>
<p className="text-sm text-muted-foreground">{body}</p>
</div>
</CarouselItem>
))}
</CarouselContent>
</Carousel>
<div role="group" aria-label="Choose a tip" className="flex items-center">
{TIPS.map((tip, index) => (
<button
key={tip.title}
type="button"
aria-label={tip.title}
aria-current={index === current ? "true" : undefined}
onClick={(event) => api?.scrollTo(index, prefersReducedMotion(event.currentTarget))}
className="group grid size-6 place-items-center rounded-full focus-ring"
>
<span
aria-hidden="true"
className={cn(
"h-1.5 rounded-full bg-border transition-[width,background-color] duration-(--duration-base) group-hover:bg-muted-foreground",
index === current ? "w-4 bg-foreground group-hover:bg-foreground" : "w-1.5"
)}
/>
</button>
))}
</div>
<p aria-live="polite" className="text-xs text-muted-foreground tabular-nums">
Tip {current + 1} of {TIPS.length}
</p>
</div>
)
}Vertical
A side panel's feed two items tall, with its arrows above and below and Up and Down on the keys.
import { StatusBadge } from "@/components/ui/status-badge"
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@/components/ui/carousel"
const INCIDENTS = [
{ title: "Intermittent timeouts calling Billing", severity: "SEV3", status: "resolved", when: "Aug 31, 18:43 – 21:59 UTC" },
{ title: "Search returning 5xx responses", severity: "SEV2", status: "resolved", when: "Aug 30, 23:58 – Aug 31, 20:31 UTC" },
{ title: "File Storage returning 5xx responses", severity: "SEV2", status: "identified", when: "Since Aug 29, 19:35 UTC" },
{ title: "Elevated error rates on API", severity: "SEV3", status: "monitoring", when: "Since Aug 23, 05:24 UTC" },
{ title: "Webhooks latency spike", severity: "SEV2", status: "resolved", when: "Aug 20, 04:30 – Aug 21, 17:08 UTC" },
]
const TONES = { identified: "danger", monitoring: "warning" } as const
// A narrow side panel's feed: two incidents at a time, moving up and down.
// The arrows sit above and below, and Up and Down move it from the keyboard —
// the keys follow the axis, so Left and Right are left to the page.
export default function CarouselVertical() {
return (
<div className="w-full max-w-xs py-11">
<Carousel orientation="vertical" aria-label="Recent incidents" opts={{ align: "start" }}>
<CarouselContent className="-mt-2 h-56">
{INCIDENTS.map((incident, index) => (
<CarouselItem key={incident.title} aria-label={`${index + 1} of ${INCIDENTS.length}`} className="basis-1/2 pt-2">
<div className="flex h-full flex-col justify-between gap-1 panel px-3 py-2.5 ring-inset">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-muted-foreground">{incident.severity}</span>
<StatusBadge status={incident.status} map={TONES} size="sm" />
</div>
<p className="text-sm font-medium">{incident.title}</p>
<p className="text-xs text-muted-foreground">{incident.when}</p>
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious aria-label="Newer incidents" />
<CarouselNext aria-label="Older incidents" />
</Carousel>
</div>
)
}With thumbnails
A strip of named thumbnail buttons under the pictures; the one on show is current and on the selected plane.
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { formatCurrency } from "@/lib/format"
import { prefersReducedMotion } from "@/hooks/use-reduced-motion"
import { Carousel, CarouselContent, CarouselItem, type CarouselApi } from "@/components/ui/carousel"
import { ProductArt } from "@/components/ui/product-art"
const BUNDLE = [
["Standing Desk", 549],
["Ergonomic Office Chair", 179],
["Desk Lamp LED", 29],
["Laptop Stand", 34],
["Mechanical Keyboard", 89],
] as const
// The home office bundle: one large picture per piece, and a strip of
// thumbnails under it. Each thumbnail is a button named for the piece it
// shows; the one on show is marked current and drawn on the selected plane.
export default function CarouselThumbnails() {
const [api, setApi] = React.useState<CarouselApi>()
const [current, setCurrent] = React.useState(0)
React.useEffect(() => {
if (!api) return
const sync = () => setCurrent(api.selectedScrollSnap())
sync()
api.on("select", sync)
return () => {
api.off("select", sync)
}
}, [api])
return (
<div className="flex w-full max-w-xs flex-col gap-3">
<Carousel aria-label="Home office bundle" setApi={setApi}>
<CarouselContent>
{BUNDLE.map(([name, price], index) => (
<CarouselItem key={name} aria-label={`${index + 1} of ${BUNDLE.length}: ${name}`}>
<ProductArt name={name} label={name} className="aspect-video" />
<p className="mt-2 flex justify-between text-sm">
<span className="font-medium">{name}</span>
<span className="text-muted-foreground tabular-nums">{formatCurrency(price, "USD", { maximumFractionDigits: 0 })}</span>
</p>
</CarouselItem>
))}
</CarouselContent>
</Carousel>
<div role="group" aria-label="Pieces in the bundle" className="grid grid-cols-5 gap-2">
{BUNDLE.map(([name], index) => (
<button
key={name}
type="button"
aria-label={`Show ${name}`}
aria-current={index === current ? "true" : undefined}
onClick={(event) => api?.scrollTo(index, prefersReducedMotion(event.currentTarget))}
className={cn("rounded-lg p-1 focus-ring hover:bg-muted", index === current && "bg-brand-muted hover:bg-brand-muted")}
>
<ProductArt name={name} className="rounded-md" />
</button>
))}
</div>
</div>
)
}In a card
The card is the region, named by its title; the carousel passes region={false} and its controls sit in the strip.
"use client"
import * as React from "react"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Carousel, CarouselContent, CarouselItem, useCarousel } from "@/components/ui/carousel"
import { ProductArt } from "@/components/ui/product-art"
const TOP_SELLERS = [
["Non-Stick Baking Sheet", 5],
["Hiking Backpack 40L", 4],
["Camping Tent 4-Person", 4],
["Smart Power Strip", 3],
["Paper Shredder", 3],
["Standing Desk", 2],
] as const
/** The strip's two buttons, read from the carousel they sit inside. At an end a button stays focusable and says it has nowhere to go. */
function StripControls() {
const { scrollPrev, scrollNext, canScrollPrev, canScrollNext } = useCarousel()
return (
<div className="flex gap-1">
<Button variant="outline" size="icon-sm" aria-label="Previous products" disabled={!canScrollPrev} focusableWhenDisabled onClick={scrollPrev}>
<ChevronLeftIcon aria-hidden="true" className="rtl:rotate-180" />
</Button>
<Button variant="outline" size="icon-sm" aria-label="Next products" disabled={!canScrollNext} focusableWhenDisabled onClick={scrollNext}>
<ChevronRightIcon aria-hidden="true" className="rtl:rotate-180" />
</Button>
</div>
)
}
// The card is the carousel: its root is the region, described as a carousel
// and named by the card's own title, so the Carousel inside passes
// region={false} rather than nest a second, unnamed region. The Carousel draws
// no box of its own (className="contents"), which leaves the card's strip and
// sheet where the card puts them, with the controls in the strip.
export default function CarouselCard() {
const titleId = React.useId()
return (
<Card role="region" aria-roledescription="carousel" aria-labelledby={titleId} className="w-full max-w-md">
<Carousel region={false} opts={{ align: "start", slidesToScroll: "auto" }} className="contents">
<CardHeader>
<CardTitle id={titleId}>Top sellers this week</CardTitle>
<CardDescription>Units sold since August 28</CardDescription>
<CardAction>
<StripControls />
</CardAction>
</CardHeader>
<CardContent>
<CarouselContent>
{TOP_SELLERS.map(([name, units], index) => (
<CarouselItem key={name} aria-label={`${index + 1} of ${TOP_SELLERS.length}`} className="basis-1/2 sm:basis-1/3">
<ProductArt name={name} />
<p className="mt-2 line-clamp-2 text-sm font-medium">{name}</p>
<p className="text-xs text-muted-foreground tabular-nums">{units} sold</p>
</CarouselItem>
))}
</CarouselContent>
</CardContent>
</Carousel>
</Card>
)
}Slides that are links
Saved reports as links; the one Tab reaches off to the side slides into view.
import { ArrowUpRightIcon } from "lucide-react"
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@/components/ui/carousel"
const REPORTS = [
{ title: "Revenue by plan", detail: "Monthly recurring revenue, split by plan", href: "/saas/revenue" },
{ title: "Late invoices", detail: "48 overdue, the oldest from October", href: "/finance/invoices" },
{ title: "Orders this week", detail: "Placed, paid and fulfilled since Aug 28", href: "/ecommerce/orders" },
{ title: "Stock to reorder", detail: "Products under their reorder point", href: "/ecommerce/inventory" },
{ title: "Retention by cohort", detail: "Accounts still paying, by signup month", href: "/saas/retention" },
]
// Saved reports, each one link. Tab walks through them in order, and a report
// the tab reaches off to the side slides into view (Embla's watchFocus, on by
// default), so the focus is never on a card nobody can see.
export default function CarouselLinks() {
return (
<div className="w-full max-w-xl px-12">
<Carousel aria-label="Saved reports" opts={{ align: "start" }}>
<CarouselContent>
{REPORTS.map((report, index) => (
<CarouselItem key={report.href} aria-label={`${index + 1} of ${REPORTS.length}`} className="basis-4/5 sm:basis-1/2">
<a
href={report.href}
className="group flex h-full flex-col gap-1 rounded-xl p-4 ring-1 ring-border ring-inset transition-colors duration-(--duration-fast) hover:bg-muted/50 focus-ring"
>
<span className="flex items-center justify-between gap-2 text-sm font-medium">
{report.title}
<ArrowUpRightIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground group-hover:text-foreground" />
</span>
<span className="text-sm text-muted-foreground">{report.detail}</span>
</a>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious aria-label="Previous reports" />
<CarouselNext aria-label="Next reports" />
</Carousel>
</div>
)
}Looping quotes
Customers in their own words, looping so neither button greys out; the faces are decoration.
import { avatarFor } from "@/lib/avatars"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@/components/ui/carousel"
const QUOTES = [
{
name: "Mira Delacroix",
role: "Head of finance, Lakeside Logistics",
quote: "We close the month in a day now. The invoices table tells us who is late before our customers do.",
},
{
name: "Hugo Tanaka",
role: "IT lead, Harborview Studio",
quote: "Passkeys took the locked-out tickets in our queue to none in the first week.",
},
{
name: "Sofia Petrova",
role: "Operations director, Cobalt Digital",
quote: "Our auditors asked who changed a role, and the audit log answered it in the meeting.",
},
]
// Customers in their own words, looping: from the last quote Next comes round
// to the first, so neither button ever greys out. Each face is decoration —
// the name and the company under it say who is speaking.
export default function CarouselQuotes() {
return (
<div className="w-full max-w-lg px-12">
<Carousel aria-label="What customers say" opts={{ loop: true }}>
<CarouselContent>
{QUOTES.map((quote, index) => (
<CarouselItem key={quote.name} aria-label={`${index + 1} of ${QUOTES.length}`}>
<figure className="flex h-full flex-col gap-4 panel p-5 ring-inset">
<blockquote className="text-pretty">“{quote.quote}”</blockquote>
<figcaption className="mt-auto flex items-center gap-3 text-sm">
<Avatar aria-hidden="true">
<AvatarImage src={avatarFor(quote.name)} alt="" />
<AvatarFallback>{getInitials(quote.name)}</AvatarFallback>
</Avatar>
<span>
<span className="block font-medium">{quote.name}</span>
<span className="text-muted-foreground">{quote.role}</span>
</span>
</figcaption>
</figure>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious aria-label="Previous quote" />
<CarouselNext aria-label="Next quote" />
</Carousel>
</div>
)
}Turns on its own
Notices that rotate until stopped: still under reduced motion, held on hover and focus, and silent while they run.
"use client"
import * as React from "react"
import { ChevronLeftIcon, ChevronRightIcon, PauseIcon, PlayIcon } from "lucide-react"
import { useInterval } from "@/hooks/use-interval"
import { useReducedMotion } from "@/hooks/use-reduced-motion"
import { Button } from "@/components/ui/button"
import { Carousel, CarouselContent, CarouselItem, type CarouselApi } from "@/components/ui/carousel"
const NOTICES = [
{ tag: "Maintenance", text: "Exports and webhooks pause on Sep 12 from 02:00 to 02:30 UTC. Nothing queued is lost." },
{ tag: "Release 4.3", text: "Passkeys, and an orders table that opens in under a second on any size of workspace." },
{ tag: "Incident", text: "Uploads to File Storage are failing while a fix rolls out. Downloads are unaffected." },
]
// Notices that turn over every six seconds — until the reader says otherwise.
// Rotation starts stopped for a reader who asked for less motion, and holds
// while a pointer rests on the banner or the focus is inside it. The first
// button stops and starts it, the last two step by hand, and while it runs the
// slides are not a live region, so a notice is never read out over whatever
// the reader is doing.
export default function CarouselAutoplay() {
const [api, setApi] = React.useState<CarouselApi>()
const reduced = useReducedMotion()
const [choice, setChoice] = React.useState<boolean | null>(null)
const [held, setHeld] = React.useState({ pointer: false, focus: false })
const playing = choice ?? !reduced
const rotating = playing && !held.pointer && !held.focus
useInterval(() => api?.scrollNext(reduced), rotating && api ? 6000 : null)
return (
<div
className="flex w-full max-w-md items-center gap-3 panel p-2"
onPointerEnter={() => setHeld((h) => ({ ...h, pointer: true }))}
onPointerLeave={() => setHeld((h) => ({ ...h, pointer: false }))}
onFocus={() => setHeld((h) => ({ ...h, focus: true }))}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setHeld((h) => ({ ...h, focus: false }))
}}
>
<Button variant="ghost" size="icon-sm" aria-label={playing ? "Stop the notices turning" : "Turn the notices"} onClick={() => setChoice(!playing)}>
{playing ? <PauseIcon aria-hidden="true" /> : <PlayIcon aria-hidden="true" />}
</Button>
<Carousel aria-label="Notices" opts={{ loop: true }} setApi={setApi} className="min-w-0 flex-1">
<CarouselContent aria-live={rotating ? "off" : "polite"}>
{NOTICES.map((notice, index) => (
<CarouselItem key={notice.tag} aria-label={`${index + 1} of ${NOTICES.length}`}>
<p className="text-sm">
<span className="font-medium">{notice.tag}.</span> <span className="text-muted-foreground">{notice.text}</span>
</p>
</CarouselItem>
))}
</CarouselContent>
</Carousel>
<Button variant="ghost" size="icon-sm" aria-label="Previous notice" onClick={() => api?.scrollPrev(reduced)}>
<ChevronLeftIcon aria-hidden="true" className="rtl:rotate-180" />
</Button>
<Button variant="ghost" size="icon-sm" aria-label="Next notice" onClick={() => api?.scrollNext(reduced)}>
<ChevronRightIcon aria-hidden="true" className="rtl:rotate-180" />
</Button>
</div>
)
}A short tour
Steps moved only by Back and Next, the ones behind inert; Done says so and becomes the way back to the start.
"use client"
import * as React from "react"
import { FilterIcon, GaugeIcon, KeyRoundIcon } from "lucide-react"
import { prefersReducedMotion } from "@/hooks/use-reduced-motion"
import { Button } from "@/components/ui/button"
import { Carousel, CarouselContent, CarouselItem, type CarouselApi } from "@/components/ui/carousel"
const STEPS = [
{ icon: KeyRoundIcon, title: "Passkeys", body: "Register one per device from Settings → Security, then sign in without a password." },
{ icon: GaugeIcon, title: "A faster orders table", body: "Orders are paged on the server, so a large workspace opens its table in under a second." },
{ icon: FilterIcon, title: "Saved filters stay put", body: "A saved filter no longer follows you into another workspace." },
]
// What is new in 4.3, one step at a time, moved only by its buttons. The steps
// behind are inert, so the tab order never wanders into one. Back rests at the
// first step, still focusable; on the last step Next becomes Done, and Done
// says so in the status line and turns into the way back to the start — the
// same button, so the focus stays where it was.
export default function CarouselTour() {
const [api, setApi] = React.useState<CarouselApi>()
const [step, setStep] = React.useState(0)
const [finished, setFinished] = React.useState(false)
const last = step === STEPS.length - 1
React.useEffect(() => {
if (!api) return
const sync = () => setStep(api.selectedScrollSnap())
api.on("select", sync)
return () => {
api.off("select", sync)
}
}, [api])
function forward(event: React.MouseEvent<HTMLButtonElement>) {
const jump = prefersReducedMotion(event.currentTarget)
if (finished) {
setFinished(false)
api?.scrollTo(0, jump)
} else if (last) {
setFinished(true)
} else {
api?.scrollNext(jump)
}
}
return (
<div className="flex w-full max-w-sm flex-col gap-4 panel p-5">
<Carousel aria-label="What is new in 4.3" setApi={setApi} opts={{ watchDrag: false }}>
<CarouselContent>
{STEPS.map(({ icon: Icon, title, body }, index) => (
<CarouselItem key={title} aria-label={`Step ${index + 1} of ${STEPS.length}`} inert={index !== step}>
<Icon aria-hidden="true" className="size-5 text-muted-foreground" />
<p className="mt-3 font-medium">{title}</p>
<p className="mt-1 text-sm text-muted-foreground">{body}</p>
</CarouselItem>
))}
</CarouselContent>
</Carousel>
<div className="flex items-center gap-2">
<p role="status" className="me-auto text-xs text-muted-foreground tabular-nums">
{finished ? "You are up to date with 4.3." : `Step ${step + 1} of ${STEPS.length}`}
</p>
<Button
size="sm"
variant="ghost"
disabled={step === 0}
focusableWhenDisabled
onClick={(event) => {
setFinished(false)
api?.scrollPrev(prefersReducedMotion(event.currentTarget))
}}
>
Back
</Button>
<Button size="sm" onClick={forward}>
{finished ? "Start over" : last ? "Done" : "Next"}
</Button>
</div>
</div>
)
}Right to left
The demo in Arabic: the row starts at the right, back sits on the right and next on the left, and Left is the key that moves on.
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@/components/ui/carousel"
const RELEASES = [
{ version: "2.14", title: "طرق عرض محفوظة", body: "ثبّت جدولًا مصفّى وشاركه برابط." },
{ version: "2.13", title: "تنبيهات الاستخدام", body: "رسالة بالبريد حين تتجاوز مساحة العمل 80% من خطتها." },
{ version: "2.12", title: "استيراد CSV", body: "استورد العملاء من جدول بيانات، مع تشغيل تجريبي أولًا." },
{ version: "2.11", title: "تصدير سجل التدقيق", body: "شهر من أحداث التدقيق، يُنزَّل بصيغة JSON." },
]
// The demo in Arabic. The row starts at the right: the newest release sits
// there, the previous button hangs off the right edge and the next off the
// left, each chevron points the way its press moves, and Left is the key that
// moves on. The carousel reads the direction off the page — nothing here tells
// it — and hands it to Embla, which lays the slides out from the right.
export default function CarouselRtl() {
return (
// The arrows sit outside the slides, so the padding keeps them on the stage.
<div dir="rtl" lang="ar" className="w-full max-w-sm px-12">
<Carousel aria-label="ما الجديد" opts={{ align: "start" }}>
<CarouselContent>
{RELEASES.map((release, index) => (
<CarouselItem key={release.version} aria-label={`${index + 1} من ${RELEASES.length}`}>
<div className="flex h-36 flex-col justify-between rounded-xl bg-surface p-4 ring-1 ring-border ring-inset">
<span className="font-mono text-xs text-muted-foreground">
<bdi>v{release.version}</bdi>
</span>
<div className="flex flex-col gap-1">
<p className="text-sm font-semibold">{release.title}</p>
<p className="text-sm text-muted-foreground">{release.body}</p>
</div>
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious aria-label="الشريحة السابقة" />
<CarouselNext aria-label="الشريحة التالية" />
</Carousel>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| region | boolean | true | The root is the carousel region, named "Slides" until named. Pass false when an ancestor carries the region, its name and aria-roledescription. |
| orientation | "horizontal" | "vertical" | "horizontal" | The axis the slides move along. |
| opts | EmblaOptionsType | — | Embla's options — loop, align, slidesToScroll. |
| plugins | EmblaPluginType[] | — | Embla plugins, such as autoplay. |
| setApi | (api: CarouselApi) => void | — | Hands you Embla's API, for a counter or dots of your own. |
Dependencies
Source
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { prefersReducedMotion } from "@/hooks/use-reduced-motion"
import { Button } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
/**
* Whether the root is the carousel region. On by default: a region described
* as a carousel and named "Slides" until you name it with aria-label or
* aria-labelledby. Off when an ancestor is the region — a section or a card
* named by its heading — so the page does not nest a second one inside it.
*/
region?: boolean
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
/** The reading direction the slides run in: the page's, unless opts.direction says otherwise. */
direction: "ltr" | "rtl"
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
/** The reading direction an element sits in: the nearest dir attribute, else its computed direction. */
function directionOf(element: HTMLElement): "ltr" | "rtl" {
const attribute = element.closest("[dir]")?.getAttribute("dir")?.toLowerCase()
if (attribute === "rtl" || attribute === "ltr") return attribute
return getComputedStyle(element).direction === "rtl" ? "rtl" : "ltr"
}
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
region = true,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const rootRef = React.useRef<HTMLDivElement>(null)
// The reading direction the carousel sits in, read off the page once it is
// mounted: Embla has to be told, or a right-to-left row starts at the wrong
// end and runs backwards. opts.direction, when given, wins.
const [pageDirection, setPageDirection] = React.useState<"ltr" | "rtl">("ltr")
React.useLayoutEffect(() => {
if (rootRef.current) setPageDirection(directionOf(rootRef.current))
}, [])
const direction = opts?.direction ?? pageDirection
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
direction,
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
// Embla moves the track from script, where the reduced-motion media query
// never reaches, so a reader who asked for less motion gets the jump rather
// than the slide — the OS setting or a [data-motion="reduced"] above.
const scrollPrev = React.useCallback(() => {
api?.scrollPrev(prefersReducedMotion(rootRef.current))
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext(prefersReducedMotion(rootRef.current))
}, [api])
// The keys follow the axis the slides move along, and a row follows the
// reading direction: in a right-to-left one the next slide is to the left.
const [backKey, forwardKey] =
orientation === "vertical"
? ["ArrowUp", "ArrowDown"]
: direction === "rtl"
? ["ArrowRight", "ArrowLeft"]
: ["ArrowLeft", "ArrowRight"]
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === backKey) {
event.preventDefault()
scrollPrev()
} else if (event.key === forwardKey) {
event.preventDefault()
scrollNext()
}
},
[backKey, forwardKey, scrollPrev, scrollNext]
)
const named = props["aria-label"] !== undefined || props["aria-labelledby"] !== undefined
const regionProps = region
? { role: "region", "aria-roledescription": "carousel", ...(named ? null : { "aria-label": "Slides" }) }
: null
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
direction,
}}
>
<div
ref={rootRef}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
{...regionProps}
data-slot="carousel"
data-orientation={orientation}
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ms-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "ps-4" : "pt-4",
className
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon-sm",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute touch-manipulation rounded-full",
orientation === "horizontal"
? "inset-y-0 -start-12 my-auto"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
{/* Back points the way the row came from: left, or right in a right-to-left row. */}
<ChevronLeftIcon className={cn(orientation === "horizontal" && "rtl:rotate-180")} />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon-sm",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute touch-manipulation rounded-full",
orientation === "horizontal"
? "inset-y-0 -end-12 my-auto"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ChevronRightIcon className={cn(orientation === "horizontal" && "rtl:rotate-180")} />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
useCarousel,
}