ComponentsMedia
Carousel
Slides that scroll along one axis, moved by swipe, by the previous and next buttons, by dots or by the arrow keys.
Import
import { Carousel, CarouselContent, CarouselDots, CarouselFooter, CarouselItem, CarouselNext, CarouselPrevious } from "@booleanpress/ui/carousel"Also install embla-carousel-react: pnpm add embla-carousel-react
Usage
Carousel is Embla with the package's classes. It needs the embla-carousel-react peer package, which only products that import @booleanpress/ui/carousel install. Each CarouselItem is one slide; CarouselFooter is a row under the slides with CarouselDots at its start and the previous and next buttons at its end.
import {
Carousel,
CarouselContent,
CarouselDots,
CarouselFooter,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@booleanpress/ui/carousel"
export function ReportSlides() {
return (
<Carousel aria-label="October reports" className="w-full max-w-xl">
<CarouselContent>
<CarouselItem>Deliveries</CarouselItem>
<CarouselItem>Bounces</CarouselItem>
<CarouselItem>Complaints</CarouselItem>
</CarouselContent>
<CarouselFooter>
<CarouselDots />
<div className="flex gap-2">
<CarouselPrevious />
<CarouselNext />
</div>
</CarouselFooter>
</Carousel>
)
}Embla's options go in opts: align ("center", the default, or "start"), loop, slidesToScroll, dragFree. A slide's width is its basis: basis-1/3 shows three at a time, basis-auto takes the content's width. orientation="vertical" scrolls up and down and needs a height on CarouselContent. Outside a CarouselFooter, CarouselPrevious and CarouselNext sit beside the slides, 48 px out from their edges, so leave room for them. To read or drive the carousel from outside, take Embla's API with setApi: api.selectedScrollSnap(), api.scrollTo(index), and its select event. Plugins, such as autoplay, go in plugins.
The carousel takes its direction from BooleanUIProvider's dir: in right-to-left, the slides run from the right, Previous sits on the right and the arrow keys follow the reading direction.
Examples
Basic
One slide at a time, with the dots and the previous and next buttons in a row under the slides.
import {
Carousel,
CarouselContent,
CarouselDots,
CarouselFooter,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@booleanpress/ui/carousel"
export default function CarouselBasic() {
return (
<Carousel aria-label="Numbered slides" className="mx-auto w-full max-w-xl">
<CarouselContent>
{Array.from({ length: 5 }, (_, index) => (
<CarouselItem key={index}>
<div className="flex h-60 items-center justify-center rounded-xl border bg-subtle text-5xl font-semibold text-primary">
{index + 1}
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselFooter>
<CarouselDots />
<div className="flex gap-2">
<CarouselPrevious />
<CarouselNext />
</div>
</CarouselFooter>
</Carousel>
)
}Alignment
opts={{ align }} lines the current slide up with the start of the viewport, or centres it (the default).
import {
Carousel,
CarouselContent,
CarouselDots,
CarouselFooter,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@booleanpress/ui/carousel"
export default function CarouselAlignment() {
return (
<div className="mx-auto flex w-full max-w-xl flex-col gap-8">
{(["start", "center"] as const).map((align) => (
<Carousel key={align} aria-label={`Slides aligned to the ${align}`} opts={{ align, containScroll: false }}>
<CarouselContent>
{Array.from({ length: 5 }, (_, index) => (
<CarouselItem key={index} className="basis-2/3">
<div className="flex h-40 items-center justify-center rounded-xl border bg-subtle text-5xl font-semibold text-primary">
{index + 1}
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselFooter>
<CarouselDots />
<div className="flex gap-2">
<CarouselPrevious />
<CarouselNext />
</div>
</CarouselFooter>
</Carousel>
))}
</div>
)
}Vertical
orientation="vertical" scrolls up and down, with Previous above and Next below; CarouselContent has a height.
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@booleanpress/ui/carousel"
export default function CarouselVertical() {
return (
<Carousel aria-label="Numbered slides" orientation="vertical" opts={{ align: "start" }} className="mx-auto my-12 w-full max-w-sm">
<CarouselContent className="h-60">
{Array.from({ length: 5 }, (_, index) => (
<CarouselItem key={index} className="basis-3/4">
<div className="flex h-full items-center justify-center rounded-xl border bg-subtle text-5xl font-semibold text-primary">
{index + 1}
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
)
}Loop
opts={{ loop: true }} goes from the last slide back to the first, so the buttons never disable.
import {
Carousel,
CarouselContent,
CarouselDots,
CarouselFooter,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@booleanpress/ui/carousel"
export default function CarouselLoop() {
return (
<Carousel aria-label="Numbered slides" opts={{ loop: true }} className="mx-auto w-full max-w-xl">
<CarouselContent>
{Array.from({ length: 5 }, (_, index) => (
<CarouselItem key={index}>
<div className="flex h-60 items-center justify-center rounded-xl border bg-subtle text-5xl font-semibold text-primary">
{index + 1}
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselFooter>
<CarouselDots />
<div className="flex gap-2">
<CarouselPrevious />
<CarouselNext />
</div>
</CarouselFooter>
</Carousel>
)
}Variable size
basis-auto slides take their own width; the dots count the scroll positions, not the slides.
import {
Carousel,
CarouselContent,
CarouselDots,
CarouselFooter,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@booleanpress/ui/carousel"
const WIDTHS = ["w-30", "w-20", "w-50", "w-40", "w-55", "w-45", "w-70", "w-25"]
export default function CarouselVariableSize() {
return (
<Carousel aria-label="Slides of different widths" opts={{ align: "start" }} className="mx-auto w-full max-w-xl">
<CarouselContent>
{WIDTHS.map((width, index) => (
<CarouselItem key={width} className="basis-auto">
<div
className={`flex h-35 ${width} items-center justify-center rounded-xl border bg-subtle text-4xl font-semibold text-primary`}
>
{index + 1}
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselFooter>
<CarouselDots />
<div className="flex gap-2">
<CarouselPrevious />
<CarouselNext />
</div>
</CarouselFooter>
</Carousel>
)
}Dots
CarouselDots alone under the slides: one button per position, the current one filled.
import { Carousel, CarouselContent, CarouselDots, CarouselItem } from "@booleanpress/ui/carousel"
const TIPS = [
{ title: "Verify your domain", text: "Add the SPF and DKIM records so mailbox providers trust your mail." },
{ title: "Send a test email", text: "Check the delivery log for the result before you go live." },
{ title: "Turn on alerts", text: "Hear about bounces and failed sends as they happen." },
]
export default function CarouselDotsExample() {
return (
<Carousel aria-label="Getting started" className="mx-auto w-full max-w-sm">
<CarouselContent>
{TIPS.map((tip) => (
<CarouselItem key={tip.title}>
<div className="flex h-40 flex-col justify-center gap-1 rounded-xl border bg-card p-6 shadow-sm">
<p className="text-base/normal font-semibold">{tip.title}</p>
<p className="text-sm/normal text-muted-foreground">{tip.text}</p>
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselDots />
</Carousel>
)
}Several per view
basis-1/3 shows three slides from 640 px (two below), and slidesToScroll: "auto" moves a page at a time.
import {
Carousel,
CarouselContent,
CarouselDots,
CarouselFooter,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@booleanpress/ui/carousel"
const MAILERS = [
{ name: "Primary", provider: "Amazon SES", sent: "12,480" },
{ name: "Backup", provider: "SMTP relay", sent: "312" },
{ name: "Marketing", provider: "Mailgun", sent: "48,920" },
{ name: "Receipts", provider: "Postmark", sent: "7,105" },
{ name: "Alerts", provider: "SendGrid", sent: "1,264" },
{ name: "Staging", provider: "Mailpit", sent: "88" },
]
export default function CarouselSeveralPerView() {
return (
<Carousel aria-label="Mailers" opts={{ align: "start", slidesToScroll: "auto" }} className="mx-auto w-full max-w-xl">
<CarouselContent>
{MAILERS.map((mailer) => (
<CarouselItem key={mailer.name} className="basis-1/2 sm:basis-1/3">
<div className="flex flex-col gap-1 rounded-xl border bg-card p-4 shadow-sm">
<p className="text-sm/normal font-semibold">{mailer.name}</p>
<p className="text-xs/normal text-muted-foreground">{mailer.provider}</p>
<p className="mt-2 text-2xl font-semibold tabular-nums">{mailer.sent}</p>
<p className="text-xs/normal text-muted-foreground">sent in October</p>
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselFooter>
<CarouselDots />
<div className="flex gap-2">
<CarouselPrevious />
<CarouselNext />
</div>
</CarouselFooter>
</Carousel>
)
}Arrows beside the slides
Outside a CarouselFooter, the previous and next buttons sit at the slides' start and end edges.
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@booleanpress/ui/carousel"
export default function CarouselArrows() {
return (
<Carousel aria-label="Numbered slides" className="mx-auto w-full max-w-xs">
<CarouselContent>
{Array.from({ length: 5 }, (_, index) => (
<CarouselItem key={index}>
<div className="flex aspect-square items-center justify-center rounded-xl border bg-subtle text-5xl font-semibold text-primary">
{index + 1}
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
)
}Accessibility
- Semantics
- A
divwithrole="region"andaria-roledescription="carousel". Each slide is arole="group"witharia-roledescription="slide", named by its position ("Slide 2 of 5"). The previous and next buttons arebuttons named "Previous slide" and "Next slide", disabled at the ends unless the carousel loops. Each dot is abuttonnamed "Go to slide 2"; the current one hasaria-current="true". - Labels
- Name the carousel with
aria-label(oraria-labelledbyon a heading), as any region. A slide's position name is replaced by your ownaria-labelonCarouselItem. Every built-in word comes from the provider's strings:carouselRole,slideRole,slideOf,previousSlide,nextSlideandgoToSlide. - Focus
- The carousel itself is not a tab stop; its buttons, its dots and the controls inside the slides are. While focus is inside it, the arrow keys move one slide. Focus stays where it is when the slide changes, except on Previous or Next as it disables at an end: focus moves to the other one, so it never falls to the page.
- Known limits
- There is no autoplay built in. Embla's autoplay plugin works through
plugins; if you add it, also add a visible pause button, as WCAG 2.2.2 asks. - Each dot is a 28 ร 8 px bar whose target is 24 px tall; dots 8 px apart meet WCAG 2.5.8.
- The arrow keys do nothing while focus is in a text field inside a slide, or in a control that uses them itself (a radio group, a slider, tabs), so the control keeps them.
- There is no autoplay built in. Embla's autoplay plugin works through
Keyboard
| Key | Behaviour |
|---|---|
| โ | With focus inside a horizontal carousel, moves to the next slide (the previous one in right-to-left). |
| โ | With focus inside a horizontal carousel, moves to the previous slide (the next one in right-to-left). |
| โ | With focus inside a vertical carousel, moves to the next slide. |
| โ | With focus inside a vertical carousel, moves to the previous slide. |
| EnterorSpace | On a button, moves to the previous or next slide; on a dot, moves to its position. |
API
Carousel
Renders a div and passes it every other prop.
| Prop | Type | Default | Description |
|---|---|---|---|
opts | Partial<OptionsType> | Embla's options: align, loop, slidesToScroll, dragFree and the rest. | |
orientation | "horizontal" | "vertical" | horizontal | The axis the slides move along. A vertical carousel needs a height on CarouselContent. |
plugins | CreatePluginType<LoosePluginType, {}>[] | Embla plugins, such as autoplay. | |
setApi | ((api: EmblaCarouselType) => void) | Receives Embla's API once it is ready, to read or drive the carousel from outside. |
CarouselContent
Renders a div and passes it every other prop.
CarouselDots
Renders a div and passes it every other prop.
CarouselFooter
Renders a div and passes it every other prop.
CarouselItem
Renders a div and passes it every other prop.
CarouselNext
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | ||
loading | boolean | Shows the spinner in place of the leading icon, sets aria-busy and aria-disabled and ignores presses (a click,
Enter, Space, a form's submission), while the button keeps its focus and its place in the tab order. With
asChild the child (a link) gets the same. | |
raised | boolean | null | ||
rounded | boolean | null | ||
severity | "success" | "info" | "warning" | "help" | "danger" | "contrast" | null | ||
size | "default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | null | icon | The button's size. icon (36 px) by default. |
variant | "link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null | outline | The button's variant. outline by default. |
CarouselPrevious
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | ||
loading | boolean | Shows the spinner in place of the leading icon, sets aria-busy and aria-disabled and ignores presses (a click,
Enter, Space, a form's submission), while the button keeps its focus and its place in the tab order. With
asChild the child (a link) gets the same. | |
raised | boolean | null | ||
rounded | boolean | null | ||
severity | "success" | "info" | "warning" | "help" | "danger" | "contrast" | null | ||
size | "default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | null | icon | The button's size. icon (36 px) by default. |
variant | "link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null | outline | The button's variant. outline by default. |
Also exported: CarouselApi, a TypeScript type.
Every part takes className, merged with its defaults by cn(), and ref, which reaches the element it renders.
Data attributes: data-slot="carousel" (Carousel), data-slot="carousel-content" (CarouselContent), data-slot="carousel-dots" (CarouselDots), data-slot="carousel-footer" (CarouselFooter), data-slot="carousel-item" (CarouselItem), data-slot="carousel-next" (CarouselNext), data-slot="carousel-previous" (CarouselPrevious), and data-orientation, data-active.
Provider strings: carouselRole, slideRole, slideOf, previousSlide, nextSlide, goToSlide (BooleanUIProvider's strings).
Theming
Slides carry no surface of their own: style what you put in them. The previous and next buttons are outline icon buttons: --border for the edge, --muted-foreground for the chevron and --subtle under the pointer. The dots are --border, --control under the pointer and --primary for the current one; focus is the --ring outline.
The tokens its classes read. Change their values in your theme, and it follows.
| Token | Used for |
|---|---|
--border | background |
--control | background |
--foreground | text |
--muted-foreground | text |
--primary | background |
--ring | outline |