# 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`
- **APG Carousel:** <https://www.w3.org/WAI/ARIA/apg/patterns/carousel/>
- **Page:** <https://ui.booleanpress.com/components/carousel> · @booleanpress/ui 0.2.0

## Usage

`Carousel` is [Embla](https://www.embla-carousel.com) 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.

```tsx
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.

```tsx
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).

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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 `button`s 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

| 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. |
| Enter or Space | 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.

| Token | Used for |
| --- | --- |
| `--border` | background |
| `--control` | background |
| `--foreground` | text |
| `--muted-foreground` | text |
| `--primary` | background |
| `--ring` | outline |
