Skip to main content
Copy for LLM

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 and announces slide changes to screen readers via a live region.

Usage

Basic Carousel
<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.

Autoplay Carousel

Without Loop

When loop is disabled, the Previous button is unavailable on the first slide and the Next button on the last slide. They are marked aria-disabled rather than disabled, so a keyboard reader who presses Next onto the last slide keeps the focus on it instead of losing their place on the page.

No-Loop Carousel

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

Image Carousel

With real images in your project:

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

Vertical carousel

Width & Layout

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

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

Three slides per view

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.

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

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

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

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.

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

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.

Behavior

  • Native scroll snapping — the track is overflow-x-auto snap-x snap-mandatory (or the -y pair when vertical) and the browser does the scrolling. Nothing is translated; there is no transition duration to tune, and the arrows and indicators call scrollTo
  • 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
label string|null null Accessible name for this slide. Undefaulted on purpose — an anonymous slide has no index of its own, so any built-in name would name every slide identically
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 groups, each of which you name with label. 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" — a group, not a tab panel. Pass label on every slide: aria-roledescription is unreliable on an element with no accessible name, so an unnamed track reads as N identical groups with nothing to tell them apart
  • Indicators: a role="group" picker of buttons; aria-current marks the slide leading the view. The current dot is filled in the accent color and the others are rings in the strong border color, so every dot reaches 3:1 against the page and the current one differs in shape as well as color. Under forced colors, which removes the fill, the current dot is filled in the system's selection color
  • 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

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, which is under the 24×24 minimum of WCAG 2.5.8 Target Size (Minimum). They meet that criterion's spacing exception instead: the row puts their centers 24px apart, in either orientation, so the 24px circles the criterion draws around them touch without overlapping. The previous/next buttons are a second route as well: they are 40×40 at the default size, are rendered unconditionally, and reach every slide the dots reach.

If you build your own indicator row, keep small indicators at least 24px apart from center to center, or size each one to 24×24.

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

The four movement keys are the scroll container's own, not a handler this component adds. The viewport is focusable and snaps mandatorily, so the browser resolves an arrow-key scroll to the next slide and Home / End to the ends of the strip — which is why there is no key binding to find in the source, and why the behavior is identical to any other snapping scroller your readers already know.

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 Current indicator dot, fill and edge
--color-wk-bg-elevated Nav-button background
--color-wk-bg-hover Nav-button hover background
--color-wk-border Nav-button border
--color-wk-border-strong Edge of an indicator dot that is not current
--color-wk-ring Focus ring (nav buttons + indicators)
--ring-wk-width Focus ring width
--border-wk-width Nav-button border width, and the indicator dots' edge (never under 1px)
--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():

use Pushery\WireKit\WireKit;

WireKit::personalize('carousel', [
    'base' => fn (string $vendor): string => str_replace('rounded-[var(--radius-wk-lg)]', 'rounded-none overflow-hidden', $vendor),
]);

WireKit::personalize('carousel.slide', [
    'base' => fn (string $vendor): string => $vendor.' p-8',
]);

Closures, because each is handed the block as it ships. A slide's block also carries snap-start, which makes the track settle on a slide, and the wk-carousel-slide marker the widths for per-view are set through; a plain string would replace them along with the rest of the block.

Further Reading

Updated in WireKit v2.66.0 (2026-10-13)

Was this page helpful?

Thank you for your feedback!

Voting requires cookies or local storage. What we store