Skip to the content

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 div with role="region" and aria-roledescription="carousel". Each slide is a role="group" with aria-roledescription="slide", named by its position ("Slide 2 of 5"). The previous and next buttons are buttons named "Previous slide" and "Next slide", disabled at the ends unless the carousel loops. Each dot is a button named "Go to slide 2"; the current one has aria-current="true".
Labels
Name the carousel with aria-label (or aria-labelledby on a heading), as any region. A slide's position name is replaced by your own aria-label on CarouselItem. Every built-in word comes from the provider's strings: carouselRole, slideRole, slideOf, previousSlide, nextSlide and goToSlide.
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.

Keyboard

Keyboard
KeyBehaviour
โ†’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.
EnterorSpaceOn 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.

Carousel props
PropTypeDefaultDescription
optsPartial<OptionsType>Embla's options: align, loop, slidesToScroll, dragFree and the rest.
orientation"horizontal" | "vertical"horizontalThe axis the slides move along. A vertical carousel needs a height on CarouselContent.
pluginsCreatePluginType<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

CarouselNext props
PropTypeDefaultDescription
asChildboolean
loadingbooleanShows 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.
raisedboolean | null
roundedboolean | null
severity"success" | "info" | "warning" | "help" | "danger" | "contrast" | null
size"default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | nulliconThe button's size. icon (36 px) by default.
variant"link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | nulloutlineThe button's variant. outline by default.

CarouselPrevious

CarouselPrevious props
PropTypeDefaultDescription
asChildboolean
loadingbooleanShows 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.
raisedboolean | null
roundedboolean | null
severity"success" | "info" | "warning" | "help" | "danger" | "contrast" | null
size"default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | nulliconThe button's size. icon (36 px) by default.
variant"link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | nulloutlineThe 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.

Theme tokens
TokenUsed for
--borderbackground
--controlbackground
--foregroundtext
--muted-foregroundtext
--primarybackground
--ringoutline