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
Progressive Carousel
Phone Showcase
4 of 6

Scrollable Card Stack — A horizontal scroll-snap carousel of profile cards with depth: the centered card is full size and crisp while neighbours scale down and dim based on their distance to the viewport center. Includes dot navigation, prev/next chevrons (flip under RTL), a polite live region, and full keyboard support; reduced motion disables the parallax for a plain snap carousel.

Preview

Aria Bennett

Aria Bennett

@ariab

Noah Carter

Noah Carter

@noahc

Maya Flores

Maya Flores

@mayaf

Leo Nakamura

Leo Nakamura

@leon

previews.components.scrollable-card-stack.default.blade.php Blade
<div class="flex min-h-40 w-full items-center justify-center rounded-lg border border-border bg-card p-8">
    <x-ui.scrollable-card-stack class="w-full max-w-md" />
</div>

Installation

terminal
php artisan ui:add scrollable-card-stack

Note

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

resources/js/ui/index.js JS
import './scrollable-card-stack.js';

Registry contract

php artisan ui:add scrollable-card-stack 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/scrollable-card-stack.blade.php
  • js resources/js/ui/scrollable-card-stack.js
Registry dependencies
avatar carousel
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.

scrollable-card-stack.md
# Brok UI: Scrollable Card Stack (`scrollable-card-stack`)

A horizontal scroll-snap carousel of profile cards with depth: the centered card is full size and crisp while neighbours scale down and dim based on their distance to the viewport center. Includes dot navigation, prev/next chevrons (flip under RTL), a polite live region, and full keyboard support; reduced motion disables the parallax for a plain snap carousel.

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

## Install

```bash
php artisan ui:add scrollable-card-stack
```

## Usage

```blade
<div class="flex min-h-40 w-full items-center justify-center rounded-lg border border-border bg-card p-8">
    <x-ui.scrollable-card-stack class="w-full max-w-md" />
</div>
```

## Props

- `cards` (mixed|null, default `null`) — Profile cards to show; each needs a name and avatar, falling back to a built-in demo set when omitted.

## Use when

- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- A horizontal set of profile/content cards that should feel physically scrollable (native scroll-snap) while still emphasising the centred card with depth.
- Wanting dot navigation, prev/next chevrons and full keyboard support over a native-feeling swipeable row.

## Avoid when

- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- A transform-driven (non-native-scroll) carousel is preferred for tighter control over 3D geometry; use card-carousel-3d or collection-surfer instead.

## Anti-patterns

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

## Rules

- Use the `<brok:scrollable-card-stack>` tag (or `<x-ui.scrollable-card-stack>`) 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/scrollable-card-stack
- Registry JSON (files, props, contract): https://brokui.dev/r/open/scrollable-card-stack.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
cards mixed | null null Profile cards to show; each needs a name and avatar, falling back to a built-in demo set when omitted.

Slots

  • default — Unused; cards are rendered from the cards prop.

Data slots

Stable hooks for CSS overrides and browser tests.

scrollable-card-stack scrollable-card-stack-card

Behavior

  • Cards scroll-snap horizontally; the card nearest the viewport centre is shown full size and crisp while neighbours scale down and dim based on their distance from centre.
  • Dot navigation, prev/next chevrons (which flip under RTL) and the arrow keys all move the active card; a polite live region announces the current position.
  • Reduced motion disables the parallax scale/dim effect, leaving a plain scroll-snap carousel.
  • 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.
  • A horizontal set of profile/content cards that should feel physically scrollable (native scroll-snap) while still emphasising the centred card with depth.
  • Wanting dot navigation, prev/next chevrons and full keyboard support over a native-feeling swipeable row.

Avoid when

  • Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
  • A transform-driven (non-native-scroll) carousel is preferred for tighter control over 3D geometry; use card-carousel-3d or collection-surfer 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 scrollable-card-stack-card
Theming hooks
scrollable-card-stack scrollable-card-stack-card

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
ArrowUp ArrowDown ArrowLeft ArrowRight Home End
Focus
managed
  • Prev/next chevrons and dots carry accessible labels, and keyboard users can move through the row without a pointer.
  • The live region announces the active card so screen-reader users get positional feedback that scroll-snap alone wouldn't provide.
  • 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="scrollable-card-stack-{{ $record->id }}">
    <div class="flex min-h-40 w-full items-center justify-center rounded-lg border border-border bg-card p-8">
        <x-ui.scrollable-card-stack class="w-full max-w-md" />
    </div>
</div>

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/scrollable-card-stack.blade.php Blade
@props([
    'cards' => null,
])

@php
    $items = $cards ?? [
        ['image' => 'https://picsum.photos/seed/scs-aria/640/360', 'name' => 'Aria Bennett', 'handle' => '@ariab'],
        ['image' => 'https://picsum.photos/seed/scs-noah/640/360', 'name' => 'Noah Carter', 'handle' => '@noahc'],
        ['image' => 'https://picsum.photos/seed/scs-maya/640/360', 'name' => 'Maya Flores', 'handle' => '@mayaf'],
        ['image' => 'https://picsum.photos/seed/scs-leo/640/360', 'name' => 'Leo Nakamura', 'handle' => '@leon'],
    ];
@endphp

<div
    x-data="uiScrollableCardStack()"
    data-slot="scrollable-card-stack"
    role="group"
    aria-roledescription="{{ __('carousel') }}"
    aria-label="{{ __('Profile cards') }}"
    {{ $attributes->merge(['class' => 'flex flex-col gap-4']) }}
>
    <div
        x-ref="track"
        @scroll="onScroll()"
        role="region"
        aria-label="{{ __('Scrollable profile cards') }}"
        tabindex="0"
        class="flex snap-x snap-mandatory gap-4 overflow-x-auto scroll-smooth rounded-md px-[12.5%] py-2 focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
    >
        @foreach ($items as $i => $card)
            <article
                role="group"
                aria-roledescription="{{ __('slide') }}"
                aria-label="{{ __('Card :n of :m', ['n' => $i + 1, 'm' => count($items)]) }}"
                :style="cardStyle()"
                data-slot="scrollable-card-stack-card"
                class="relative w-3/4 shrink-0 snap-center overflow-hidden rounded-lg border border-border bg-card text-card-foreground shadow-sm transition-transform duration-200 will-change-transform"
            >
                <div class="aspect-[16/9] w-full overflow-hidden bg-muted">
                    <img
                        src="{{ $card['image'] }}"
                        alt=""
                        loading="lazy"
                        class="size-full object-cover"
                    />
                </div>
                <div class="flex items-center gap-3 p-4">
                    <x-ui.avatar
                        :src="$card['image']"
                        :alt="$card['name']"
                        size="md"
                        class="-mt-10 border-4 border-card shadow-sm"
                    />
                    <div class="min-w-0">
                        <p class="truncate text-sm font-semibold leading-tight">{{ $card['name'] }}</p>
                        <p class="truncate text-sm text-muted-foreground">{{ $card['handle'] }}</p>
                    </div>
                </div>
            </article>
        @endforeach
    </div>

    {{-- This stack's Alpine scope already matches carousel.previous/next's own
         defaults for the click handler (prev()/next()); only the boundary
         check (active===0 / active===count-1, no canPrev/canNext) and the
         inline (not overlay) sizing need overriding. The dot row composes
         carousel.dots against this component's own `active` name. --}}
    <div class="flex items-center justify-between gap-4" @keydown="onKey($event)">
        <x-ui.carousel.dots :count="count($items)" active="active" class="gap-2" />

        <div class="flex items-center gap-2">
            <x-ui.carousel.previous
                disabled="active === 0"
                aria-label="{{ __('Previous card') }}"
                class="!static !size-9 !rounded-full !border-input hover:!bg-accent hover:!text-accent-foreground"
            />
            <x-ui.carousel.next
                disabled="active === count - 1"
                aria-label="{{ __('Next card') }}"
                class="!static !size-9 !rounded-full !border-input hover:!bg-accent hover:!text-accent-foreground"
            />
        </div>
    </div>

    <div class="sr-only" aria-live="polite" x-text="liveLabel"></div>
</div>
resources/js/ui/scrollable-card-stack.js JS
/**
 * Scrollable Card Stack behavior — a horizontal scroll-snap carousel with depth.
 *
 * The track is a native `overflow-x-auto` scroll-snap row, so touch and trackpad
 * scrolling work without JS. On each scroll (throttled to one update per animation
 * frame via `requestAnimationFrame`) we measure every card's distance from the
 * track's horizontal center and apply a `transform: scale()` so the
 * centered card is full size/crisp and neighbours recede — a parallax depth effect.
 * The same measurement tracks the `active` index, which drives the dot tablist,
 * the prev/next buttons, and a polite live-region announcement.
 *
 * Direction-aware: `goTo` uses the card's own offset within the track, so it works
 * identically under `dir="rtl"`; the chevrons are flipped in the Blade with
 * `rtl:-scale-x-100`. Keyboard (Arrow/Home/End) moves the active card.
 *
 * Under `prefers-reduced-motion: reduce` the scale parallax is disabled —
 * every card renders at full size (a plain snap carousel).
 *
 * The scroll listener is registered declaratively in the Blade (`@scroll`), and an
 * IntersectionObserver-free design keeps teardown simple; the only owned resource
 * is the pending rAF handle, cancelled in `destroy()` so unmounting mid-scroll
 * cannot leak a queued callback.
 *
 * 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('uiScrollableCardStack', () => ({
        active: 0,
        count: 0,
        liveLabel: '',

        /** Pending requestAnimationFrame handle (so we can cancel on destroy). */
        _raf: null,

        init() {
            this.count = this.cards().length;
            // Initial paint of the depth transforms once layout has settled.
            this.$nextTick(() => this.measure());
        },

        destroy() {
            if (this._raf !== null) {
                cancelAnimationFrame(this._raf);
                this._raf = null;
            }
        },

        get reduceMotion() {
            return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
        },

        // Every card in the track. (Not `$refs`: Alpine keeps one element per
        // ref name, so a repeated `x-ref` only ever yielded the last card.)
        cards() {
            return [...this.$root.querySelectorAll('[data-slot="scrollable-card-stack-card"]')];
        },

        /** Throttle measurement to one update per frame. */
        onScroll() {
            if (this._raf !== null) return;
            this._raf = requestAnimationFrame(() => {
                this._raf = null;
                this.measure();
            });
        },

        /**
         * Measure each card's distance from the track center; set the active index
         * to the nearest card and announce it. The per-card transform itself is read
         * reactively by `cardStyle`, so we just trigger a re-render by touching state.
         */
        measure() {
            const track = this.$refs.track;
            const cards = this.cards();
            if (!track || !cards.length) return;

            const trackRect = track.getBoundingClientRect();
            const center = trackRect.left + trackRect.width / 2;

            let nearest = 0;
            let nearestDist = Infinity;
            cards.forEach((card, i) => {
                const r = card.getBoundingClientRect();
                const cardCenter = r.left + r.width / 2;
                const dist = Math.abs(cardCenter - center);
                if (dist < nearestDist) {
                    nearestDist = dist;
                    nearest = i;
                }
            });

            if (nearest !== this.active) {
                this.active = nearest;
                this.announce();
            }
            // Bump a reactive read so :style bindings recompute on every frame.
            this._tick = (this._tick ?? 0) + 1;
        },

        /**
         * Inline transform for a card based on how far its center is from
         * the track center, normalised by track width. Returns plain numeric CSS
         * (no colour) so it satisfies the token rules. Disabled under reduced motion.
         */
        cardStyle() {
            // Touch the tick + active so Alpine recomputes this on scroll.
            void this._tick;
            void this.active;

            const track = this.$refs.track;
            const cards = this.cards();
            if (this.reduceMotion || !track || !cards.length) {
                return 'transform: scale(1)';
            }

            // `cardStyle` is called per element; resolve which card via $el at runtime.
            const el = this.$el;
            const trackRect = track.getBoundingClientRect();
            const center = trackRect.left + trackRect.width / 2;
            const r = el.getBoundingClientRect();
            const cardCenter = r.left + r.width / 2;

            const half = trackRect.width / 2 || 1;
            const t = Math.min(Math.abs(cardCenter - center) / half, 1);

            const scale = 1 - t * 0.16; // 1.0 centered → 0.84 at the edge
            return `transform: scale(${scale.toFixed(4)})`;
        },

        /** Smoothly scroll a card to the track center; direction-agnostic. */
        goTo(i) {
            const cards = this.cards();
            if (!cards[i]) return;
            cards[i].scrollIntoView({
                behavior: this.reduceMotion ? 'auto' : 'smooth',
                inline: 'center',
                block: 'nearest',
            });
        },

        next() {
            if (this.active < this.count - 1) this.goTo(this.active + 1);
        },

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

        /** Mirror the active card's own (already-localized) label into the live region. */
        announce() {
            const cards = this.cards();
            const card = cards[this.active];
            this.liveLabel = card ? card.getAttribute('aria-label') || '' : '';
        },

        onKey(e) {
            const rtl = getComputedStyle(this.$el).direction === 'rtl';
            switch (e.key) {
                case 'ArrowRight':
                    e.preventDefault();
                    rtl ? this.prev() : this.next();
                    break;
                case 'ArrowLeft':
                    e.preventDefault();
                    rtl ? this.next() : this.prev();
                    break;
                case 'ArrowDown':
                    e.preventDefault();
                    this.next();
                    break;
                case 'ArrowUp':
                    e.preventDefault();
                    this.prev();
                    break;
                case 'Home':
                    e.preventDefault();
                    this.goTo(0);
                    break;
                case 'End':
                    e.preventDefault();
                    this.goTo(this.count - 1);
                    break;
            }
        },
    }));
});

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