Skip to content
Brok UI

Loading…

No results

Carousels

Open source

Scrolling card carousels — a flat slide-and-snap track, a 3D perspective coverflow, a rotating 3D ring and a centre-emphasis phone-screen coverflow.

Version
v1.2.3
Stability
stable
License
MIT
Related
3D Card Carousel
Cylinder Carousel
Scrollable Card Stack
Progressive Carousel
Phone Showcase
1 of 6

Carousel — A slideshow for cycling through slides with keyboard and button controls, optional autoplay with pause-on-hover, and horizontal or vertical orientation. previous/next accept an onClick/disabled override so they compose into a standalone host; dots renders a tablist of position indicators.

Preview

1
2
3
4
5
Orientation
previews.components.carousel.default.blade.php Blade
<x-ui.carousel class="mx-auto w-full max-w-sm px-12">
    <x-ui.carousel.content>
        @foreach (range(1, 5) as $slide)
            <x-ui.carousel.item>
                <div class="flex aspect-square items-center justify-center rounded-md border border-border bg-card p-6 text-3xl font-semibold text-card-foreground">
                    {{ $slide }}
                </div>
            </x-ui.carousel.item>
        @endforeach
    </x-ui.carousel.content>
    <x-ui.carousel.previous />
    <x-ui.carousel.next />
</x-ui.carousel>
Horizontal Current
Vertical Current

Installation

terminal
php artisan ui:add carousel

Note

This component ships an Alpine behavior module at resources/js/ui/carousel.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './carousel.js';

Registry contract

php artisan ui:add carousel writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/carousel.blade.php
  • blade resources/views/components/ui/carousel/content.blade.php
  • blade resources/views/components/ui/carousel/item.blade.php
  • blade resources/views/components/ui/carousel/previous.blade.php
  • blade resources/views/components/ui/carousel/next.blade.php
  • blade resources/views/components/ui/carousel/dots.blade.php
  • js resources/js/ui/carousel.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

carousel.md
# Brok UI: Carousel (`carousel`)

A slideshow for cycling through slides with keyboard and button controls, optional autoplay with pause-on-hover, and horizontal or vertical orientation. previous/next accept an onClick/disabled override so they compose into a standalone host; dots renders a tablist of position indicators.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add carousel
```

## Usage

```blade
<x-ui.carousel class="mx-auto w-full max-w-sm px-12">
    <x-ui.carousel.content>
        @foreach (range(1, 5) as $slide)
            <x-ui.carousel.item>
                <div class="flex aspect-square items-center justify-center rounded-md border border-border bg-card p-6 text-3xl font-semibold text-card-foreground">
                    {{ $slide }}
                </div>
            </x-ui.carousel.item>
        @endforeach
    </x-ui.carousel.content>
    <x-ui.carousel.previous />
    <x-ui.carousel.next />
</x-ui.carousel>
```

## Props

- `loop` (bool, default `false`) — Wraps from the last slide back to the first and vice versa when true.
- `orientation` (string, default `horizontal`) — horizontal or vertical track direction.
- `autoplay` (bool, default `false`) — Advances automatically on a timer when true, gated behind reduced motion.
- `interval` (int, default `4000`) — Milliseconds between automatic slide advances when autoplay is true.
- `onClick` (string, default `prev()`) — Declared by @props in the registry Blade source.
- `disabled` (string, default `!canPrev()`) — Declared by @props in the registry Blade source.
- `count` (int, default `0`) — Declared by @props in the registry Blade source.
- `active` (string, default `index`) — Declared by @props in the registry Blade source.
- `goTo` (string, default `goTo`) — Declared by @props in the registry Blade source.

## Use when

- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Building a standard slideshow of content panels with keyboard, button and optional autoplay navigation, horizontal or vertical.
- Composing a custom carousel host: previous/next accept onClick/disabled overrides and dots accepts active/goTo overrides so they drive any Alpine scope, not just this component's own.

## Avoid when

- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- Inherent 3D depth or a coverflow look is wanted; use card-carousel-3d, carousel-cylinder, collection-surfer or phone-showcase, which build that presentation on top of these same previous/next/dots parts.

## Anti-patterns

- Heavy effects in forms or task-dense workflows
- Motion that interferes with reading or focus

## Rules

- Use the `<brok:carousel>` tag (or `<x-ui.carousel>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/carousel
- Registry JSON (files, props, contract): https://brokui.dev/r/open/carousel.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
loop bool false Wraps from the last slide back to the first and vice versa when true.
orientation string horizontal horizontal or vertical track direction.
autoplay bool false Advances automatically on a timer when true, gated behind reduced motion.
interval int 4000 Milliseconds between automatic slide advances when autoplay is true.
onClick string prev() Declared by @props in the registry Blade source.
disabled string !canPrev() Declared by @props in the registry Blade source.
count int 0 Declared by @props in the registry Blade source.
active string index Declared by @props in the registry Blade source.
goTo string goTo Declared by @props in the registry Blade source.

Slots

  • default — Carousel parts: carousel.content wrapping carousel.item slides, plus carousel.previous/next/dots.
  • x-ui.carousel.content — Installed subcomponent from the registry item.
  • x-ui.carousel.item — Installed subcomponent from the registry item.
  • x-ui.carousel.previous — Installed subcomponent from the registry item.
  • x-ui.carousel.next — Installed subcomponent from the registry item.
  • x-ui.carousel.dots — Installed subcomponent from the registry item.

Data slots

Stable hooks for CSS overrides and browser tests.

carousel carousel-content carousel-dot carousel-dots carousel-item carousel-track {{ $slotName }}

Behavior

  • Tracks the active slide index and translates the track by that index; loop wraps from the last slide back to the first and vice versa.
  • autoplay advances every interval ms, is gated behind prefers-reduced-motion, and pauses on hover/focus; the timer is torn down on destroy so it never leaks.
  • Arrow keys and the track's translate direction follow reading order, so they swap automatically under RTL; orientation=vertical stacks the track on the Y axis instead.
  • Uses region semantics, keyboard navigation, button controls, and aria-disabled state for boundaries when loop is off.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.

Guidance

Expressive and showcase effect

Support brand expression or a showcase moment.

Use when

  • Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
  • Building a standard slideshow of content panels with keyboard, button and optional autoplay navigation, horizontal or vertical.
  • Composing a custom carousel host: previous/next accept onClick/disabled overrides and dots accepts active/goTo overrides so they drive any Alpine scope, not just this component's own.

Avoid when

  • Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
  • Inherent 3D depth or a coverflow look is wanted; use card-carousel-3d, carousel-cylinder, collection-surfer or phone-showcase, which build that presentation on top of these same previous/next/dots parts.

Use instead

  • Static content-first presentation

Anti-patterns

  • Heavy effects in forms or task-dense workflows
  • Motion that interferes with reading or focus
Anatomy
root carousel-content carousel-track carousel-dots carousel-dot carousel-item
Theming hooks
carousel carousel-content carousel-dot carousel-dots carousel-item carousel-track {{ $slotName }}

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
native
Focus
managed
  • The root is role=region with aria-roledescription=carousel, each item is role=group aria-roledescription=slide, and the dots row is role=tablist with each dot role=tab and aria-selected.
  • Previous/Next fade out (rather than sitting disabled) only when there is truly nowhere to go, so the control never looks broken mid-carousel.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<div wire:key="carousel-{{ $record->id }}">
    <x-ui.carousel class="mx-auto w-full max-w-sm px-12">
        <x-ui.carousel.content>
            @foreach (range(1, 5) as $slide)
                <x-ui.carousel.item>
                    <div class="flex aspect-square items-center justify-center rounded-md border border-border bg-card p-6 text-3xl font-semibold text-card-foreground">
                        {{ $slide }}
                    </div>
                </x-ui.carousel.item>
            @endforeach
        </x-ui.carousel.content>
        <x-ui.carousel.previous />
        <x-ui.carousel.next />
    </x-ui.carousel>
</div>

Validation

Validation support: native. Keep the error message connected with aria-describedby.

livewire-form.blade.php Blade
<form wire:submit="save" class="space-y-2">
    <brok:carousel
        wire:model="value"
        :aria-invalid="$errors->has('value') ? 'true' : 'false'"
        aria-describedby="value-error"
    />

    @error('value')
        <p id="value-error" role="alert">{{ $message }}</p>
    @enderror

    <brok:button type="submit" wire:loading.attr="disabled">
        <span wire:loading.remove>Save</span>
        <span wire:loading>Saving…</span>
    </brok:button>
</form>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/carousel.blade.php Blade
@props([
    'loop' => false,
    'orientation' => 'horizontal',
    'autoplay' => false,
    'interval' => 4000,
])

@php
    $vertical = $orientation === 'vertical';
@endphp

<div
    x-data="uiCarousel({ loop: @js((bool) $loop), vertical: @js($vertical), autoplay: @js((bool) $autoplay), interval: @js((int) $interval) })"
    data-slot="carousel"
    data-orientation="{{ $vertical ? 'vertical' : 'horizontal' }}"
    role="region"
    aria-roledescription="carousel"
    tabindex="0"
    @keydown="onKey($event)"
    @if ($autoplay) @mouseenter="pause()" @mouseleave="resume()" @focusin="pause()" @focusout="resume()" @endif
    {{ $attributes->merge(['class' => 'relative rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring']) }}
>{{ $slot }}</div>
resources/views/components/ui/carousel/content.blade.php Blade
<div data-slot="carousel-content" {{ $attributes->merge(['class' => 'overflow-hidden']) }}><div data-slot="carousel-track" x-ref="track" class="flex motion-safe:transition-transform motion-safe:duration-300 motion-reduce:transition-none" :class="vertical ? 'h-full flex-col' : 'flex-row'" :style="`transform: translate${vertical ? 'Y' : 'X'}(${trackOffset()}%)`">{{ $slot }}</div></div>
resources/views/components/ui/carousel/item.blade.php Blade
<div data-slot="carousel-item" role="group" aria-roledescription="slide" {{ $attributes->merge(['class' => 'min-w-0 shrink-0 grow-0 basis-full']) }}>{{ $slot }}</div>
resources/views/components/ui/carousel/previous.blade.php Blade
@props([
    // Override the click handler so this button can drive a standalone host's
    // own Alpine scope (e.g. carousel-cylinder's prev()) instead of
    // carousel's own prev().
    'onClick' => 'prev()',
    // Override the disabled expression to match a standalone host's own
    // boundary check (e.g. an endless ring carousel that is never disabled
    // passes disabled="false").
    'disabled' => '!canPrev()',
])

@php
    // A standalone host composing this outside carousel passes its own
    // data-slot/aria-label; the browser keeps only the first of two
    // identical attributes, so read each override and print exactly one.
    $slotName = (string) ($attributes->get('data-slot') ?: 'carousel-previous');
    $label = (string) ($attributes->get('aria-label') ?: __('Previous slide'));
@endphp

{{-- An arrow with nowhere to go fades out instead of sitting greyed out
     like a broken control; it comes back the moment there is a slide. --}}
<button
    type="button"
    data-slot="{{ $slotName }}"
    aria-label="{{ $label }}"
    @click="{{ $onClick }}"
    :disabled="{{ $disabled }}"
    {{--
        `vertical` is read from the enclosing x-data scope, but a standalone
        host composing this control has no reason to define an unused prop —
        `typeof` never throws on an undeclared identifier, so a host without
        `vertical` simply gets the horizontal layout instead of an Alpine
        expression error.
    --}}
    :class="[(typeof vertical !== 'undefined' && vertical) ? 'top-3 start-1/2 -translate-x-1/2' : 'start-3 top-1/2 -translate-y-1/2', ({{ $disabled }}) ? 'pointer-events-none opacity-0' : '']"
    {{ $attributes->except(['data-slot', 'aria-label'])->merge(['class' => 'absolute inline-flex size-8 items-center justify-center rounded-full border border-border bg-background text-foreground shadow-sm transition-[color,background-color,opacity] duration-200 hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none']) }}
><svg class="size-4 rtl:-scale-x-100" :class="(typeof vertical !== 'undefined' && vertical) ? 'rotate-90' : ''" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg></button>
resources/views/components/ui/carousel/next.blade.php Blade
@props([
    // Override the click handler so this button can drive a standalone host's
    // own Alpine scope (e.g. carousel-cylinder's next()) instead of
    // carousel's own next().
    'onClick' => 'next()',
    // Override the disabled expression to match a standalone host's own
    // boundary check (e.g. an endless ring carousel that is never disabled
    // passes disabled="false").
    'disabled' => '!canNext()',
])

@php
    // A standalone host composing this outside carousel passes its own
    // data-slot/aria-label; the browser keeps only the first of two
    // identical attributes, so read each override and print exactly one.
    $slotName = (string) ($attributes->get('data-slot') ?: 'carousel-next');
    $label = (string) ($attributes->get('aria-label') ?: __('Next slide'));
@endphp

{{-- An arrow with nowhere to go fades out instead of sitting greyed out
     like a broken control; it comes back the moment there is a slide. --}}
<button
    type="button"
    data-slot="{{ $slotName }}"
    aria-label="{{ $label }}"
    @click="{{ $onClick }}"
    :disabled="{{ $disabled }}"
    {{--
        `vertical` is read from the enclosing x-data scope, but a standalone
        host composing this control has no reason to define an unused prop —
        `typeof` never throws on an undeclared identifier, so a host without
        `vertical` simply gets the horizontal layout instead of an Alpine
        expression error.
    --}}
    :class="[(typeof vertical !== 'undefined' && vertical) ? 'bottom-3 start-1/2 -translate-x-1/2' : 'end-3 top-1/2 -translate-y-1/2', ({{ $disabled }}) ? 'pointer-events-none opacity-0' : '']"
    {{ $attributes->except(['data-slot', 'aria-label'])->merge(['class' => 'absolute inline-flex size-8 items-center justify-center rounded-full border border-border bg-background text-foreground shadow-sm transition-[color,background-color,opacity] duration-200 hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none']) }}
><svg class="size-4 rtl:-scale-x-100" :class="(typeof vertical !== 'undefined' && vertical) ? 'rotate-90' : ''" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></button>
resources/views/components/ui/carousel/dots.blade.php Blade
@props([
    // Number of dots to render (one per slide).
    'count' => 0,
    // Alpine expression (evaluated in the enclosing x-data scope) that reads
    // the current active index. Defaults to carousel's own `index`;
    // a standalone host with a differently-named property (e.g. `active`)
    // overrides this so the dot row still reads its real state.
    'active' => 'index',
    // Alpine method name (in the enclosing x-data scope) called with the
    // target index when a dot is activated. Defaults to carousel's own
    // `goTo(i)`; override to match a standalone host's own method name.
    'goTo' => 'goTo',
])

{{-- A row of dots, one per slide; the active dot widens and fills instead of
     just tinting, so the current position reads clearly even for someone who
     can't perceive the colour change. role="tablist" pairs with each dot's
     role="tab" + aria-selected so assistive tech announces the row as a set
     of alternatives, not a list of buttons. --}}
<div
    data-slot="carousel-dots"
    role="tablist"
    aria-label="{{ __('Choose slide') }}"
    {{ $attributes->merge(['class' => 'flex items-center gap-2']) }}
>
    @for ($i = 0; $i < (int) $count; $i++)
        <button
            type="button"
            data-slot="carousel-dot"
            role="tab"
            @click="{{ $goTo }}({{ $i }})"
            x-bind:aria-selected="{{ $active }} === {{ $i }} ? 'true' : 'false'"
            class="flex size-7 items-center justify-center rounded-full focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-[length:var(--ring-offset-width)] focus-visible:ring-offset-background"
            aria-label="{{ __('Go to slide :n', ['n' => $i + 1]) }}"
        >
            <span
                x-bind:class="{{ $active }} === {{ $i }} ? 'w-5 bg-primary' : 'w-2 bg-muted-foreground hover:bg-foreground'"
                class="h-2 rounded-full transition-all"
                aria-hidden="true"
            ></span>
        </button>
    @endfor
</div>
resources/js/ui/carousel.js JS
/**
 * Carousel behavior (spec §19 behavior layer).
 *
 * Tracks the active slide index on the root component and drives the track
 * transform. Direction-aware: in RTL the (horizontal) track shifts the opposite
 * way and the ArrowLeft/ArrowRight keys are swapped so navigation always follows
 * reading order. `loop` wraps from the last slide to the first and vice versa.
 *
 * Orientation: with `vertical: true` the track stacks and translates on the Y
 * axis (RTL no longer applies); the content wrapper supplies the height.
 *
 * Autoplay: with `autoplay: true` the carousel advances every `interval` ms.
 * It is gated behind `prefers-reduced-motion`, paused on hover/focus via the
 * Blade's pause()/resume() handlers, and torn down in destroy() so the timer
 * never leaks. When not looping it stops at the last slide.
 *
 * Self-registers on `alpine:init` so import order does not matter — import this
 * file once from your bundle (e.g. resources/js/ui/index.js).
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiCarousel', (config = {}) => ({
        index: 0,
        loop: config.loop ?? false,
        vertical: config.vertical ?? false,
        autoplay: config.autoplay ?? false,
        interval: config.interval ?? 4000,
        reduced: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
        _timer: null,

        init() {
            if (this.autoplay && !this.reduced) {
                this.start();
            }
        },

        count() {
            return this.$refs.track ? this.$refs.track.children.length : 0;
        },

        next() {
            if (this.index < this.count() - 1) {
                this.index++;
            } else if (this.loop) {
                this.index = 0;
            } else if (this.autoplay) {
                this.pause();
            }
        },

        prev() {
            if (this.index > 0) {
                this.index--;
            } else if (this.loop) {
                this.index = this.count() - 1;
            }
        },

        goTo(i) {
            this.index = Math.max(0, Math.min(i, this.count() - 1));
        },

        canPrev() {
            return this.loop || this.index > 0;
        },

        canNext() {
            return this.loop || this.index < this.count() - 1;
        },

        trackOffset() {
            const rtl = !this.vertical && getComputedStyle(this.$el).direction === 'rtl';
            return (rtl ? 1 : -1) * this.index * 100;
        },

        // Autoplay timer control.
        start() {
            this.pause();
            this._timer = setInterval(() => this.next(), this.interval);
        },

        pause() {
            if (this._timer) {
                clearInterval(this._timer);
                this._timer = null;
            }
        },

        resume() {
            if (this.autoplay && !this.reduced && !this._timer) {
                this.start();
            }
        },

        onKey(e) {
            const rtl = getComputedStyle(this.$el).direction === 'rtl';
            const fwd = this.vertical ? 'ArrowDown' : 'ArrowRight';
            const back = this.vertical ? 'ArrowUp' : 'ArrowLeft';
            if (e.key === fwd) {
                e.preventDefault();
                rtl && !this.vertical ? this.prev() : this.next();
            } else if (e.key === back) {
                e.preventDefault();
                rtl && !this.vertical ? this.next() : this.prev();
            }
        },

        destroy() {
            this.pause();
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.2.3
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0