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.1.1
Stability
stable
License
MIT
Related
Carousel
3D Card Carousel
Cylinder Carousel
Scrollable Card Stack
Phone Showcase
5 of 6

Progressive Carousel — An auto-advancing carousel whose active panel expands while the rest collapse to slivers — pauses on hover/focus, advances with arrow keys, and respects reduced motion. `trigger="hover"` previews a panel on hover instead (the image-accordion shape).

Preview

previews.components.progressive-carousel.default.blade.php Blade
<x-ui.progressive-carousel
    class="max-w-2xl"
    :interval="3500"
    :items="[
        ['title' => 'Discover', 'subtitle' => 'Find what moves you', 'src' => 'https://picsum.photos/seed/pc-a/800/600'],
        ['title' => 'Create', 'subtitle' => 'Make it your own', 'src' => 'https://picsum.photos/seed/pc-b/800/600'],
        ['title' => 'Share', 'subtitle' => 'Bring others along', 'src' => 'https://picsum.photos/seed/pc-c/800/600'],
        ['title' => 'Grow', 'subtitle' => 'Scale with confidence', 'src' => 'https://picsum.photos/seed/pc-d/800/600'],
    ]"
/>

Installation

terminal
php artisan ui:add progressive-carousel

Note

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

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

Registry contract

php artisan ui:add progressive-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/progressive-carousel.blade.php
  • js resources/js/ui/progressive-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.

progressive-carousel.md
# Brok UI: Progressive Carousel (`progressive-carousel`)

An auto-advancing carousel whose active panel expands while the rest collapse to slivers — pauses on hover/focus, advances with arrow keys, and respects reduced motion. `trigger="hover"` previews a panel on hover instead (the image-accordion shape).

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

## Install

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

## Usage

```blade
<x-ui.progressive-carousel
    class="max-w-2xl"
    :interval="3500"
    :items="[
        ['title' => 'Discover', 'subtitle' => 'Find what moves you', 'src' => 'https://picsum.photos/seed/pc-a/800/600'],
        ['title' => 'Create', 'subtitle' => 'Make it your own', 'src' => 'https://picsum.photos/seed/pc-b/800/600'],
        ['title' => 'Share', 'subtitle' => 'Bring others along', 'src' => 'https://picsum.photos/seed/pc-c/800/600'],
        ['title' => 'Grow', 'subtitle' => 'Scale with confidence', 'src' => 'https://picsum.photos/seed/pc-d/800/600'],
    ]"
/>
```

## Props

- `items` (array, default `[]`) — Panels in the strip; each needs a title, with optional subtitle, src and alt.
- `interval` (int, default `4000`) — Milliseconds between automatic panel advances.
- `autoplay` (bool, default `true`) — Advances the active panel automatically on a timer when true.
- `vertical` (bool, default `false`) — Stacks panels top-to-bottom instead of side-by-side when true.
- `trigger` (string, default `click`) — click pins the active panel on click/focus; hover previews a panel on hover and pins on click/focus/leave — the image-accordion shape.

## Use when

- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- An auto-advancing strip of panels where the active panel should visually expand and the rest collapse to slivers, such as a feature or destination showcase.
- Wanting a hover-preview accordion of images (trigger="hover") that pins to the last clicked/focused panel on pointer-leave — the image-accordion shape.

## Avoid when

- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- A conventional carousel with equally-sized slides is wanted; use carousel, which doesn't expand/collapse panels.
- 3D depth or a coverflow look is wanted; use card-carousel-3d or carousel-cylinder instead.

## Anti-patterns

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

## Rules

- Use the `<brok:progressive-carousel>` tag (or `<x-ui.progressive-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/progressive-carousel
- Registry JSON (files, props, contract): https://brokui.dev/r/open/progressive-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
items array [] Panels in the strip; each needs a title, with optional subtitle, src and alt.
interval int 4000 Milliseconds between automatic panel advances.
autoplay bool true Advances the active panel automatically on a timer when true.
vertical bool false Stacks panels top-to-bottom instead of side-by-side when true.
trigger string click click pins the active panel on click/focus; hover previews a panel on hover and pins on click/focus/leave — the image-accordion shape.

Slots

  • default — Unused; panels are rendered from the items prop.

Data slots

Stable hooks for CSS overrides and browser tests.

image-accordion progressive-carousel-item {{ $slotName }}

Behavior

  • The active panel expands (flex-grow) while the rest collapse to slivers; by default it auto-advances on a timer that pauses on hover/focus and is gated behind prefers-reduced-motion.
  • trigger="hover" instead previews a panel on hover without pinning it, and snaps back to the last clicked/focused panel when the pointer leaves.
  • Arrow keys (orientation- and RTL-aware), Home and End move and focus the active panel; clicking or focusing a panel pins it as active.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, responsive, rtl, darkMode, localized, interactive, reducedMotion.

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.
  • An auto-advancing strip of panels where the active panel should visually expand and the rest collapse to slivers, such as a feature or destination showcase.
  • Wanting a hover-preview accordion of images (trigger="hover") that pins to the last clicked/focused panel on pointer-leave — the image-accordion shape.

Avoid when

  • Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
  • A conventional carousel with equally-sized slides is wanted; use carousel, which doesn't expand/collapse panels.
  • 3D depth or a coverflow look is wanted; use card-carousel-3d or carousel-cylinder instead.

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 image-accordion progressive-carousel-item
Theming hooks
image-accordion progressive-carousel-item {{ $slotName }}

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
Home End
Focus
managed
  • The root is role=group with aria-roledescription=carousel; each panel is a real button with aria-current marking the active one, so it's reachable and operable without a pointer.
  • Panel titles fade in/out only on the active panel, so the accessible name is always present in the DOM even while transitioning.
  • 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="progressive-carousel-{{ $record->id }}">
    <x-ui.progressive-carousel
        class="max-w-2xl"
        :interval="3500"
        :items="[
            ['title' => 'Discover', 'subtitle' => 'Find what moves you', 'src' => 'https://picsum.photos/seed/pc-a/800/600'],
            ['title' => 'Create', 'subtitle' => 'Make it your own', 'src' => 'https://picsum.photos/seed/pc-b/800/600'],
            ['title' => 'Share', 'subtitle' => 'Bring others along', 'src' => 'https://picsum.photos/seed/pc-c/800/600'],
            ['title' => 'Grow', 'subtitle' => 'Scale with confidence', 'src' => 'https://picsum.photos/seed/pc-d/800/600'],
        ]"
    />
</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:progressive-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/progressive-carousel.blade.php Blade
@props([
    'items' => [],
    'interval' => 4000,
    'autoplay' => true,
    'vertical' => false,
    // click (default): the active panel changes only on click/focus.
    // hover: the active panel previews on hover and pins on click/focus/leave
    // — the image-accordion shape. Typically paired with :autoplay="false".
    'trigger' => 'click',
])

@php
    // Progressive Carousel — an auto-advancing strip of panels where the active
    // panel expands (flex-grow) and the rest collapse to slivers, cycling on a
    // timer. Pauses on hover/focus, advances with Arrow keys, and lets you click
    // any panel to make it active. Autoplay is gated behind reduced motion and
    // its timer is torn down in destroy(). Panels with no `src` fall back to a
    // token gradient so it renders with no external assets. `trigger="hover"`
    // additionally previews a panel on hover without pinning it (image-accordion
    // shares this DOM outline, so hover-preview is a variant prop here rather
    // than a separate component — see docs/adr/002-compositions-and-variants.md).
    $vertical = (bool) $vertical;
    $trigger = in_array($trigger, ['hover', 'click'], true) ? $trigger : 'click';
    $items = ! empty($items) ? $items : [
        ['title' => 'Discover', 'subtitle' => 'Find what moves you'],
        ['title' => 'Create', 'subtitle' => 'Make it your own'],
        ['title' => 'Share', 'subtitle' => 'Bring others along'],
        ['title' => 'Grow', 'subtitle' => 'Scale with confidence'],
    ];
    $count = count($items);

    // image-accordion composes this as <x-ui.progressive-carousel
    // trigger="hover" data-slot="image-accordion">; the browser keeps only
    // the first of two identical attributes, so read the override and print
    // exactly one.
    $slotName = (string) ($attributes->get('data-slot') ?: 'progressive-carousel');
@endphp

<div
    data-slot="{{ $slotName }}"
    data-orientation="{{ $vertical ? 'vertical' : 'horizontal' }}"
    data-trigger="{{ $trigger }}"
    x-data="uiProgressiveCarousel({ count: {{ $count }}, interval: {{ (int) $interval }}, autoplay: @js((bool) $autoplay), vertical: @js($vertical), trigger: @js($trigger) })"
    @mouseenter="pause()"
    @mouseleave="resume()"
    @focusin="pause()"
    @focusout="resume()"
    @keydown="onKey($event)"
    role="group"
    aria-roledescription="{{ __('carousel') }}"
    aria-label="{{ __('Progressive carousel') }}"
    {{ $attributes->except('data-slot')->merge(['class' => ($vertical ? 'flex-col ' : 'flex-row ').'flex h-80 w-full gap-2 overflow-hidden']) }}
>
    @foreach ($items as $i => $item)
        <button
            type="button"
            data-slot="progressive-carousel-item"
            @if ($trigger === 'hover') @mouseenter="preview({{ $i }})" @mouseleave="release()" @endif
            @click="goTo({{ $i }})"
            @focus="goTo({{ $i }})"
            :class="isActive({{ $i }}) ? 'flex-[5]' : 'flex-[1]'"
            :aria-current="isActive({{ $i }})"
            class="group relative min-h-0 min-w-0 cursor-pointer overflow-hidden rounded-lg border border-border text-start transition-all duration-700 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none"
        >
            @if (! empty($item['src']))
                <img src="{{ $item['src'] }}" alt="{{ $item['alt'] ?? ($item['title'] ?? '') }}" loading="lazy" decoding="async" class="absolute inset-0 h-full w-full object-cover" />
            @else
                <span aria-hidden="true" class="absolute inset-0 bg-gradient-to-br from-primary/30 via-card to-secondary/40"></span>
            @endif
            <span aria-hidden="true" class="absolute inset-0 bg-gradient-to-t from-background/85 via-background/20 to-transparent"></span>

            <span
                class="absolute bottom-0 start-0 flex flex-col p-5 transition-all duration-500"
                :class="isActive({{ $i }}) ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-2'"
            >
                <span class="text-base font-semibold text-foreground">{{ __($item['title'] ?? '') }}</span>
                @if (! empty($item['subtitle']))
                    <span class="mt-0.5 text-sm text-muted-foreground">{{ __($item['subtitle']) }}</span>
                @endif
            </span>
        </button>
    @endforeach
</div>
resources/js/ui/progressive-carousel.js JS
/**
 * Progressive Carousel behavior.
 *
 * Default (`trigger="click"`): advances the active panel on a timer (looping),
 * with the active panel growing and the rest collapsing — the size is driven
 * by fixed `flex-[…]` classes in the Blade. Autoplay is disabled under
 * `prefers-reduced-motion`, paused on hover/focus via the Blade's
 * pause()/resume() handlers, and the interval is cleared in destroy() so it
 * can never leak. Arrow keys (orientation-aware, RTL-aware) step the active
 * panel and move focus to it; clicking/focusing a panel activates it and
 * "pins" it as the panel autoplay/hover-release returns to.
 *
 * `trigger="hover"` (the image-accordion shape): the active panel follows the
 * pointer via preview()/release() and snaps back to the last clicked/focused
 * ("pinned") panel on pointer-leave, instead of advancing on a timer —
 * typically paired with `:autoplay="false"`.
 *
 * Self-registers on `alpine:init` so import order does not matter.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiProgressiveCarousel', (config = {}) => ({
        count: config.count ?? 0,
        interval: config.interval ?? 4000,
        autoplay: config.autoplay ?? true,
        vertical: config.vertical ?? false,
        trigger: config.trigger ?? 'click',
        reduced: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
        active: 0,
        pinned: 0,
        _timer: null,

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

        isActive(i) {
            return this.active === i;
        },

        goTo(i) {
            const idx = ((i % this.count) + this.count) % this.count;
            this.active = idx;
            this.pinned = idx;
        },

        /** trigger="hover" only: the active panel follows the pointer without pinning. */
        preview(i) {
            if (this.trigger === 'hover') this.active = i;
        },

        /** trigger="hover" only: on pointer-leave, snap back to the pinned panel. */
        release() {
            if (this.trigger === 'hover') this.active = this.pinned;
        },

        next() {
            this.goTo(this.active + 1);
        },

        prev() {
            this.goTo(this.active - 1);
        },

        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 keys = ['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown', 'Home', 'End'];
            if (!keys.includes(e.key)) return;

            const rtl = getComputedStyle(this.$el).direction === 'rtl';
            const fwd = this.vertical ? 'ArrowDown' : 'ArrowRight';
            const back = this.vertical ? 'ArrowUp' : 'ArrowLeft';
            let i = this.active;

            if (e.key === 'Home') {
                i = 0;
            } else if (e.key === 'End') {
                i = this.count - 1;
            } else if (e.key === fwd) {
                i = rtl && !this.vertical ? this.active - 1 : this.active + 1;
            } else if (e.key === back) {
                i = rtl && !this.vertical ? this.active + 1 : this.active - 1;
            } else {
                return;
            }

            e.preventDefault();
            this.goTo(i);
            const items = this.$el.querySelectorAll('[data-slot="progressive-carousel-item"]');
            if (items[this.active]) items[this.active].focus();
        },

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

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.1.1
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