---
title: Carousel
description: Image/content carousel with autoplay
visibility: guest
draft: false
---

# Carousel

The `<x-wirekit::carousel>` component creates a slide-based content rotator with autoplay, loop navigation, and indicator dots. It follows the [WAI-ARIA Carousel pattern](https://www.w3.org/WAI/ARIA/apg/patterns/carousel/) and announces slide changes to screen readers via a live region.

## Usage

:::preview{title="Basic Carousel"}
<x-wirekit::carousel>
    <x-wirekit::carousel.slide>
        <div style="padding: 3rem 2rem; text-align: center;">
            Slide 1 — Welcome to the carousel.
        </div>
    </x-wirekit::carousel.slide>
    <x-wirekit::carousel.slide>
        <div style="padding: 3rem 2rem; text-align: center;">
            Slide 2 — Second slide content.
        </div>
    </x-wirekit::carousel.slide>
    <x-wirekit::carousel.slide>
        <div style="padding: 3rem 2rem; text-align: center;">
            Slide 3 — Third slide content.
        </div>
    </x-wirekit::carousel.slide>
</x-wirekit::carousel>
:::

```blade
<x-wirekit::carousel>
    <x-wirekit::carousel.slide>
        <div class="p-12 text-center">
            <h3>Slide 1</h3>
            <p>Welcome to the carousel.</p>
        </div>
    </x-wirekit::carousel.slide>

    <x-wirekit::carousel.slide>
        <div class="p-12 text-center">
            <h3>Slide 2</h3>
            <p>Second slide content.</p>
        </div>
    </x-wirekit::carousel.slide>

    <x-wirekit::carousel.slide>
        <div class="p-12 text-center">
            <h3>Slide 3</h3>
            <p>Third slide content.</p>
        </div>
    </x-wirekit::carousel.slide>
</x-wirekit::carousel>
```

### With Autoplay

Enable automatic slide advancement. The carousel pauses on hover and focus, then resumes on mouse leave and blur.

:::preview{title="Autoplay Carousel"}
<x-wirekit::carousel :autoplay="true" :interval="3000">
    <x-wirekit::carousel.slide>
        <div style="padding: 3rem 2rem; text-align: center;">Slide 1 — auto-advances every 3 seconds.</div>
    </x-wirekit::carousel.slide>
    <x-wirekit::carousel.slide>
        <div style="padding: 3rem 2rem; text-align: center;">Slide 2 — pauses on hover.</div>
    </x-wirekit::carousel.slide>
    <x-wirekit::carousel.slide>
        <div style="padding: 3rem 2rem; text-align: center;">Slide 3 — resumes on mouse leave.</div>
    </x-wirekit::carousel.slide>
</x-wirekit::carousel>
:::

### Without Loop

When `loop` is disabled, the Previous button is disabled on the first slide and the Next button is disabled on the last slide.

:::preview{title="No-Loop Carousel"}
<x-wirekit::carousel :loop="false">
    <x-wirekit::carousel.slide>
        <div style="padding: 3rem 2rem; text-align: center;">First slide — Previous button is disabled.</div>
    </x-wirekit::carousel.slide>
    <x-wirekit::carousel.slide>
        <div style="padding: 3rem 2rem; text-align: center;">Last slide — Next button is disabled.</div>
    </x-wirekit::carousel.slide>
</x-wirekit::carousel>
:::

### Image Carousel

Slides can contain any content — images, cards, or complex layouts:

:::preview{title="Image Carousel"}
<x-wirekit::carousel :autoplay="true" :interval="4000" aria-label="Product photos">
    <x-wirekit::carousel.slide>
        <div style="height: 16rem; display: flex; flex-direction: column; align-items: center; justify-content: center; background: linear-gradient(135deg, #3b82f6, #1d4ed8); color: white;">
            <svg style="width: 3rem; height: 3rem; margin-bottom: 0.5rem;" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0 0 22.5 18.75V5.25A2.25 2.25 0 0 0 20.25 3H3.75A2.25 2.25 0 0 0 1.5 5.25v13.5A2.25 2.25 0 0 0 3.75 21Z" /></svg>
            <strong>Product Front View</strong>
        </div>
    </x-wirekit::carousel.slide>
    <x-wirekit::carousel.slide>
        <div style="height: 16rem; display: flex; flex-direction: column; align-items: center; justify-content: center; background: linear-gradient(135deg, #8b5cf6, #6d28d9); color: white;">
            <svg style="width: 3rem; height: 3rem; margin-bottom: 0.5rem;" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0 0 22.5 18.75V5.25A2.25 2.25 0 0 0 20.25 3H3.75A2.25 2.25 0 0 0 1.5 5.25v13.5A2.25 2.25 0 0 0 3.75 21Z" /></svg>
            <strong>Product Side View</strong>
        </div>
    </x-wirekit::carousel.slide>
    <x-wirekit::carousel.slide>
        <div style="height: 16rem; display: flex; flex-direction: column; align-items: center; justify-content: center; background: linear-gradient(135deg, #ec4899, #be185d); color: white;">
            <svg style="width: 3rem; height: 3rem; margin-bottom: 0.5rem;" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0 0 22.5 18.75V5.25A2.25 2.25 0 0 0 20.25 3H3.75A2.25 2.25 0 0 0 1.5 5.25v13.5A2.25 2.25 0 0 0 3.75 21Z" /></svg>
            <strong>Product Detail</strong>
        </div>
    </x-wirekit::carousel.slide>
</x-wirekit::carousel>
:::

With real images in your project:

```blade
<x-wirekit::carousel :autoplay="true" :interval="4000" aria-label="Product photos">
    <x-wirekit::carousel.slide>
        <img src="/images/product-1.jpg" alt="Product front view" class="w-full h-64 object-cover" />
    </x-wirekit::carousel.slide>
    <x-wirekit::carousel.slide>
        <img src="/images/product-2.jpg" alt="Product side view" class="w-full h-64 object-cover" />
    </x-wirekit::carousel.slide>
</x-wirekit::carousel>
```

## Vertical Orientation

Set `orientation="vertical"` to scroll the slides up and down instead of left/right. The carousel pins a default viewport height (override it with a `style="height: …"` or a height class); each slide fills that viewport automatically, and the previous/next buttons move to the top and bottom edges.

:::preview{title="Vertical carousel"}
<x-wirekit::carousel orientation="vertical" style="height: 14rem; width: 24rem;">
    <x-wirekit::carousel.slide>
        <div style="height: 100%; display: flex; align-items: center; justify-content: center; background: var(--color-wk-bg-muted);">
            <strong>First step</strong>
        </div>
    </x-wirekit::carousel.slide>
    <x-wirekit::carousel.slide>
        <div style="height: 100%; display: flex; align-items: center; justify-content: center; background: var(--color-wk-bg-subtle);">
            <strong>Second step</strong>
        </div>
    </x-wirekit::carousel.slide>
    <x-wirekit::carousel.slide>
        <div style="height: 100%; display: flex; align-items: center; justify-content: center; background: var(--color-wk-bg-muted);">
            <strong>Third step</strong>
        </div>
    </x-wirekit::carousel.slide>
</x-wirekit::carousel>
:::

## Width & Layout

The carousel fills its parent width. Constrain it with Tailwind classes directly:

```blade
<x-wirekit::carousel class="max-w-2xl mx-auto">
    <x-wirekit::carousel.slide>…</x-wirekit::carousel.slide>
</x-wirekit::carousel>
```

Slide height is determined by content. For fixed-height slides, apply height classes to the slide content.

## Multiple slides per view

`per-view` shows more than one slide at a time — a product row, a logo rail, a
gallery strip. The browser scrolls and snaps; nothing is translated.

:::preview{title="Three slides per view"}
<x-wirekit::carousel :per-view="3" label="Featured products">
    <x-wirekit::carousel.slide>
        <x-wirekit::card><x-wirekit::card.body style="text-align: center;">
            <x-wirekit::heading level="3" size="md">Desk lamp</x-wirekit::heading>
            <x-wirekit::price :amount="4900" currency="EUR" />
        </x-wirekit::card.body></x-wirekit::card>
    </x-wirekit::carousel.slide>
    <x-wirekit::carousel.slide>
        <x-wirekit::card><x-wirekit::card.body style="text-align: center;">
            <x-wirekit::heading level="3" size="md">Notebook</x-wirekit::heading>
            <x-wirekit::price :amount="1200" currency="EUR" />
        </x-wirekit::card.body></x-wirekit::card>
    </x-wirekit::carousel.slide>
    <x-wirekit::carousel.slide>
        <x-wirekit::card><x-wirekit::card.body style="text-align: center;">
            <x-wirekit::heading level="3" size="md">Chair</x-wirekit::heading>
            <x-wirekit::price :amount="24900" currency="EUR" />
        </x-wirekit::card.body></x-wirekit::card>
    </x-wirekit::carousel.slide>
    <x-wirekit::carousel.slide>
        <x-wirekit::card><x-wirekit::card.body style="text-align: center;">
            <x-wirekit::heading level="3" size="md">Monitor</x-wirekit::heading>
            <x-wirekit::price :amount="39900" currency="EUR" />
        </x-wirekit::card.body></x-wirekit::card>
    </x-wirekit::carousel.slide>
    <x-wirekit::carousel.slide>
        <x-wirekit::card><x-wirekit::card.body style="text-align: center;">
            <x-wirekit::heading level="3" size="md">Keyboard</x-wirekit::heading>
            <x-wirekit::price :amount="8900" currency="EUR" />
        </x-wirekit::card.body></x-wirekit::card>
    </x-wirekit::carousel.slide>
</x-wirekit::carousel>
:::

`per-view` takes `1`–`4`. Each step keeps a narrower layout on small screens —
three-up on a 375px phone is three unreadable slivers — and shows part of the next
slide, which is what tells the reader there *is* a next slide.

It only applies horizontally. A vertical carousel is one slide tall by
construction, so there is no second slot to share.

## Naming the carousel

`label` is what a screen reader reads first, and it makes the carousel a landmark
someone can jump straight to:

```blade
<x-wirekit::carousel label="Customer stories">…</x-wirekit::carousel>
```

Name it for what it holds. "Carousel" is the default and says nothing.

## Navigation

The carousel provides three navigation methods:

- **Previous / Next buttons** — circular buttons positioned at the left and right edges
- **Indicator dots** — clickable dot indicators at the bottom center
- **Autoplay** — automatic slide advancement with configurable interval

## Autoplay Behavior

`autoplay` rotates the slides on a timer. When you turn it on, the carousel grows
a **play/pause button** — because anything that moves on its own for more than five
seconds needs a way to stop it (WCAG 2.2.2), and pausing on hover is not that way:
a touch user cannot hover, and neither can a keyboard user driving a screen reader.

```blade
<x-wirekit::carousel autoplay :interval="4000" label="Highlights">…</x-wirekit::carousel>
```

Hovering or focusing the carousel still pauses the rotation — but that is separate
from the button. A mouse passing over the carousel does not make the button claim
you paused it.

::: info Reduced motion is handled for you
Under `prefers-reduced-motion: reduce` the carousel stays still until asked: the
auto-advance holds, and moving between slides jumps rather than glides. The play
button remains available, so a reader who wants the rotation can start it.
:::

## Keyboard

| Key | Action |
| --- | --- |
| Tab | Move focus to navigation buttons and indicator dots |
| Enter / Space | Activate the focused button or indicator |

## Behavior

- **CSS translate** — slides move via `translateX()` with a 500ms ease-in-out transition
- **Autoplay** — `setInterval` timer managed by Alpine; cleaned up in `destroy()`
- **Slide counting** — total slides are counted from `[data-wk-carousel-slide]` elements in the DOM
- **Livewire navigate** — autoplay timer is cleared on `destroy()` to prevent leaks during SPA navigation

## Props

### `<x-wirekit::carousel>`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `autoplay` | `bool` | `false` | Automatically advance slides |
| `interval` | `int` | `5000` | Autoplay interval in milliseconds |
| `loop` | `bool` | `true` | Wrap around from last slide to first (and vice versa) |
| `orientation` | `string` | `'horizontal'` | `'horizontal'` (left/right) or `'vertical'` (up/down; pins a default viewport height) |
| `perView` | `int` | `1` | Slides visible at once — `1`–`4` (horizontal only; an unknown value degrades to `1`) |
| `label` | `string` | `'Carousel'` | Accessible name for the carousel scroll region |
| `scope` | `string\|null` | `null` | Scoped personalization key |

### `<x-wirekit::carousel.slide>`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `scope` | `string\|null` | `null` | Scoped personalization key |

## Sub-Components

| Component | Purpose |
| --- | --- |
| `carousel.slide` | Individual content panel within the carousel |

## Accessibility

This is the **non-tabbed** carousel pattern: a named region containing a scrollable
list of labeled groups. It is deliberately not a tablist — a tablist means exactly
one selected panel, and `per-view` shows several slides at once, so there would be no
single panel for a tab to point at.

- Container: `role="region"`, `aria-roledescription="carousel"`, `aria-label` from the `label` prop
- Scroll region: `tabindex="0"` with its own label, so the track is reachable and scrollable by keyboard
- Slides: `role="group"`, `aria-roledescription="slide"` — each slide is a labeled group, not a tab panel
- Indicators: a `role="group"` picker of buttons; `aria-current` marks the slide leading the view
- Nav buttons: `aria-label="Previous slide"` / `aria-label="Next slide"`
- Chevron SVGs: `aria-hidden="true"` (decorative)
- Live region: `aria-live="polite"` announces "Slide X of Y" on every slide change
- Disabled buttons: native `disabled` attribute when loop is off and at first/last slide

::: tip
Override the default label when the carousel content is not images — for example,
`label="Testimonials"` or `label="Feature highlights"`. It is the first thing a screen
reader reads.
:::

### Indicator size on touch

The indicator dots are 8×8 and sit close together, which is under the 24×24 minimum
of [WCAG 2.5.8 Target Size (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html)
and too close for its spacing exception. They rely on that criterion's **equivalent
control** exception instead: the previous/next buttons are 40×40, are rendered
unconditionally, and reach every slide the dots reach.

That means the dots are a shortcut, never the only route. If you build your own
indicator row, either keep the built-in navigation buttons or size your indicators to
24×24 with 24px between their centers — do not ship the dots alone at this size.

## Keyboard Interaction

| Key | Action |
|-----|--------|
| `Tab` | Move focus into the carousel |
| `ArrowLeft` | Move to the previous slide |
| `ArrowRight` | Move to the next slide |
| `Home` | Jump to the first slide |
| `End` | Jump to the last slide |
| `Enter` / `Space` | Activate the focused navigation button or indicator dot |

## Pitfalls

- **Don't use a carousel for primary content.** WCAG research consistently finds <1% of users interact past the first slide. For must-see content use a hero with all items inline.
- **Don't auto-rotate without a pause control.** WCAG 2.2.2 — anything moving longer than 5s needs a user-controllable pause.

## Design Tokens

| Token | Used for |
| --- | --- |
| `--font-wk-sans` | Caption font family |
| `--color-wk-text` | Caption text |
| `--color-wk-accent` | Active indicator dot |
| `--color-wk-bg-elevated` | Nav-button background |
| `--color-wk-bg-subtle` | Inactive indicator dot |
| `--color-wk-border` | Nav-button border |
| `--color-wk-ring` | Focus ring (nav buttons + indicators) |
| `--ring-wk-width` | Focus ring width |
| `--border-wk-width` | Border width |
| `--radius-wk-lg` | Slide + nav-button border radius |
| `--shadow-wk-md` | Nav-button drop shadow |
| `--padding-wk-x-sm` / `--padding-wk-y-sm` | Nav-button padding |
| `--opacity-wk-disabled` | Disabled indicator visual weight |
| `--transition-wk-duration` | Slide transition |

## Personalization

Override classes globally via `WireKit::personalize()`:

```php
use Pushery\WireKit\WireKit;

WireKit::personalize('carousel', [
    'base' => 'relative overflow-hidden rounded-none',
]);

WireKit::personalize('carousel.slide', [
    'base' => 'w-full shrink-0 p-8',
]);
```

## Further Reading

- [WAI-ARIA Carousel Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/carousel/) — the authoring pattern this component implements
- [WCAG 2.2.2: Pause, Stop, Hide](https://www.w3.org/WAI/WCAG21/Understanding/pause-stop-hide.html) — why autoplay pauses on hover/focus
- [MDN: `aria-roledescription`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-roledescription)
