Skip to content
Brok UI

Loading…

No results

Card Stack

Open source

Stacked card decks — a click-to-cycle layered deck, a pointer-draggable swipe deck, and an orbiting 3D ring.

Version
v1.0.3
Stability
stable
License
MIT
Related
Layered Stack
Orbit Card Stack

Draggable Card Stack — A swipeable card deck: drag the top card with the pointer and flick it past a threshold to fling it away and bring the next card forward, recycling dismissed cards to the back. Keyboard- and button-operable, reduced-motion safe.

Preview

Step 1

Drag to explore

Grab the top card and move it left or right to browse the deck.

Step 2

Flick to dismiss

Release past the threshold and the card flies away on its own.

Step 3

Use the keys

Focus the stack and press the arrow keys or space to advance.

Step 4

Loop forever

Dismissed cards recycle to the back so you never run out.

previews.components.draggable-card-stack.default.blade.php Blade
<div class="w-full flex justify-center py-6">
    <x-ui.draggable-card-stack />
</div>

Installation

terminal
php artisan ui:add draggable-card-stack

Note

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

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

Registry contract

php artisan ui:add draggable-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/draggable-card-stack.blade.php
  • js resources/js/ui/draggable-card-stack.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.

draggable-card-stack.md
# Brok UI: Draggable Card Stack (`draggable-card-stack`)

A swipeable card deck: drag the top card with the pointer and flick it past a threshold to fling it away and bring the next card forward, recycling dismissed cards to the back. Keyboard- and button-operable, reduced-motion safe.

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

## Install

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

## Usage

```blade
<div class="w-full flex justify-center py-6">
    <x-ui.draggable-card-stack />
</div>
```

## Props

- `cards` (array, default `[]`) — Cards in the deck; each needs a title, with optional eyebrow/tag and description.
- `loop` (bool, default `true`) — Recycles dismissed cards to the back forever when true; otherwise the deck empties as cards are dismissed.

## Use when

- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Building a swipe-to-decide interaction (like/skip, keep/discard) where the visible card should physically fling away in the swipe direction.
- Needing a deck that always has explicit Skip/Like buttons as a non-drag fallback, alongside pointer drag.

## Avoid when

- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- The deck should advance by simply clicking the front card or a Next control rather than dragging; use layered-stack, which is lighter and has no drag/fling logic.
- Cards should orbit or rotate around a centre point rather than being dismissed frontward; use orbit-card-stack.

## Anti-patterns

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

## Rules

- Use the `<brok:draggable-card-stack>` tag (or `<x-ui.draggable-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/draggable-card-stack
- Registry JSON (files, props, contract): https://brokui.dev/r/open/draggable-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 array [] Cards in the deck; each needs a title, with optional eyebrow/tag and description.
loop bool true Recycles dismissed cards to the back forever when true; otherwise the deck empties as cards are dismissed.

Slots

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

Data slots

Stable hooks for CSS overrides and browser tests.

draggable-card-stack draggable-card-stack-item draggable-card-stack-like draggable-card-stack-skip

Behavior

  • The front card follows the pointer during a drag (translate plus a small rotation); releasing past a threshold flings it off in the drag direction and recycles it to the back when loop is true, otherwise it springs back home.
  • Cards behind the front one peek out, offset down, scaled down and faded, based on their depth in the order.
  • Reduced-motion users get pointer dragging and the fling/rotation disabled entirely; the deck is instead operated instantly via the arrow keys, space, or the Skip/Like buttons.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized, 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.
  • Building a swipe-to-decide interaction (like/skip, keep/discard) where the visible card should physically fling away in the swipe direction.
  • Needing a deck that always has explicit Skip/Like buttons as a non-drag fallback, alongside pointer drag.

Avoid when

  • Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
  • The deck should advance by simply clicking the front card or a Next control rather than dragging; use layered-stack, which is lighter and has no drag/fling logic.
  • Cards should orbit or rotate around a centre point rather than being dismissed frontward; use orbit-card-stack.

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 draggable-card-stack-item draggable-card-stack-skip draggable-card-stack-like
Theming hooks
draggable-card-stack draggable-card-stack-item draggable-card-stack-like draggable-card-stack-skip

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
Space ArrowLeft ArrowRight
Focus
managed
  • The stack is a focusable role=group with a live region that announces the current front card; the Skip/Like buttons carry their own accessible labels.
  • Only the front card is interactive; cards behind it are aria-hidden and cannot be dragged.
  • 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="draggable-card-stack-{{ $record->id }}">
    <div class="w-full flex justify-center py-6">
        <x-ui.draggable-card-stack />
    </div>
</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:draggable-card-stack
        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/draggable-card-stack.blade.php Blade
@props([
    // Cards rendered into the deck. Each item: { title, description, eyebrow?/tag? }.
    // Neutral placeholder copy — no PII — so the component renders something useful
    // out of the box.
    'cards' => [],
    // Whether dismissed cards recycle to the back forever (infinite loop).
    'loop' => true,
])

@php
    // Fall back to a sensible 4-item demo deck when no cards are supplied.
    $cards = ! empty($cards) ? $cards : [
        ['eyebrow' => 'Step 1', 'title' => 'Drag to explore', 'description' => 'Grab the top card and move it left or right to browse the deck.'],
        ['eyebrow' => 'Step 2', 'title' => 'Flick to dismiss', 'description' => 'Release past the threshold and the card flies away on its own.'],
        ['eyebrow' => 'Step 3', 'title' => 'Use the keys', 'description' => 'Focus the stack and press the arrow keys or space to advance.'],
        ['eyebrow' => 'Step 4', 'title' => 'Loop forever', 'description' => 'Dismissed cards recycle to the back so you never run out.'],
    ];

    $config = [
        'count' => count($cards),
        'loop' => (bool) $loop,
    ];
@endphp

<div
    x-data="uiDraggableCardStack(@js($config))"
    data-slot="draggable-card-stack"
    {{ $attributes->merge(['class' => 'flex w-full max-w-sm flex-col items-center gap-4']) }}
>
    {{-- Fixed-size relative arena so the absolutely-positioned cards have a footprint. --}}
    <div
        x-ref="stack"
        tabindex="0"
        role="group"
        :aria-roledescription="@js(__('Card deck'))"
        :aria-label="frontLabel()"
        @keydown.arrow-left.prevent="dismiss(-1)"
        @keydown.arrow-right.prevent="dismiss(1)"
        @keydown.space.prevent="dismiss(1)"
        class="relative h-72 w-full max-w-sm touch-none select-none rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
    >
        @foreach ($cards as $i => $card)
            <div
                data-slot="draggable-card-stack-item"
                x-show="isVisible({{ $i }})"
                :style="cardStyle({{ $i }})"
                @class([
                    'absolute inset-0 flex flex-col justify-between rounded-lg border border-border bg-card p-6 text-card-foreground shadow-lg',
                    'origin-bottom will-change-transform',
                    // Motion gated: reduced-motion users get instant reorder, no fling.
                    'motion-safe:transition-[transform,opacity] motion-safe:duration-300 motion-safe:ease-out',
                ])
                :class="depth({{ $i }}) === 0 && 'cursor-grab active:cursor-grabbing'"
                @pointerdown="depth({{ $i }}) === 0 && startDrag($event)"
                :aria-hidden="depth({{ $i }}) === 0 ? 'false' : 'true'"
            >
                <div class="flex flex-col gap-2">
                    @if (! empty($card['eyebrow']) || ! empty($card['tag']))
                        <p class="text-xs font-medium uppercase tracking-wide text-muted-foreground">
                            {{ $card['eyebrow'] ?? $card['tag'] }}
                        </p>
                    @endif
                    <p class="text-xl font-semibold leading-tight">{{ $card['title'] ?? '' }}</p>
                    @if (! empty($card['description']))
                        <p class="text-sm text-muted-foreground">{{ $card['description'] }}</p>
                    @endif
                </div>

                {{-- Decorative drag hint, only on the front card. Purely visual. --}}
                <p
                    x-show="depth({{ $i }}) === 0"
                    aria-hidden="true"
                    class="text-xs text-muted-foreground motion-reduce:hidden"
                >&larr; {{ __('Drag or flick') }} &rarr;</p>
            </div>
        @endforeach
    </div>

    {{-- Button controls — always operable, the reduced-motion fallback path. --}}
    <div class="flex items-center gap-3">
        <button
            type="button"
            @click="dismiss(-1)"
            data-slot="draggable-card-stack-skip"
            class="inline-flex h-10 items-center justify-center rounded-full border border-input bg-background px-5 text-sm font-medium text-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
            :aria-label="@js(__('Skip card'))"
        >{{ __('Skip') }}</button>
        <button
            type="button"
            @click="dismiss(1)"
            data-slot="draggable-card-stack-like"
            class="inline-flex h-10 items-center justify-center rounded-full bg-primary px-5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
            :aria-label="@js(__('Like card'))"
        >{{ __('Like') }}</button>
    </div>

    {{-- Polite live region announcing the current card position for assistive tech. --}}
    <p class="sr-only" aria-live="polite" x-text="frontLabel()"></p>
</div>
resources/js/ui/draggable-card-stack.js JS
/**
 * Draggable card stack behavior.
 *
 * The deck is an `order` array of card indices, front-most first. Each card's
 * depth (its position in `order`) drives translateY / scale / opacity / z-index
 * via :style in the template, so the cards behind the front one peek out (offset
 * down, scaled down, faded). The front card is draggable with the pointer:
 *   - pointerdown captures the start point and attaches move/up listeners to the
 *     window so the drag keeps tracking even if the pointer leaves the card;
 *   - pointermove updates reactive dragX/dragY which feed back into the front
 *     card's transform (translate + a little rotation);
 *   - pointerup, if the card was flicked past the threshold, flings it off in the
 *     drag direction and recycles it to the back; otherwise it springs home.
 *
 * Reduced motion: when `prefers-reduced-motion: reduce`, pointer dragging and the
 * fling/rotation are disabled entirely — the deck is operated instantly via the
 * arrow keys, space, or the Skip/Like buttons. Motion-gating of the reorder
 * transition itself is handled by the `motion-safe:` classes in the template.
 *
 * Cleanup: window pointer listeners are always removed on pointerup, and again in
 * destroy() so unmounting mid-drag can't leak; any pending rAF is cancelled.
 *
 * Self-registers on `alpine:init` so import order doesn't matter — import this
 * file once from your bundle (e.g. resources/js/ui/index.js).
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiDraggableCardStack', (config = {}) => ({
        count: config.count ?? 0,
        loop: config.loop ?? true,
        order: [],

        // Live drag offset of the front card (px). Drives its :style transform.
        dragX: 0,
        dragY: 0,
        dragging: false,

        // Distance (px) a card must travel before release dismisses it.
        threshold: 120,

        // Internal drag bookkeeping / teardown handles.
        _startX: 0,
        _startY: 0,
        _flinging: false,
        _raf: null,
        _onMove: null,
        _onUp: null,

        init() {
            this.order = Array.from({ length: this.count }, (_, i) => i);
            // Bind once so add/removeEventListener reference the same function.
            this._onMove = (e) => this._move(e);
            this._onUp = (e) => this._up(e);
        },

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

        prefersReducedMotion() {
            return (
                typeof window !== 'undefined' &&
                window.matchMedia &&
                window.matchMedia('(prefers-reduced-motion: reduce)').matches
            );
        },

        // --- Deck geometry -------------------------------------------------

        frontIndex() {
            return this.order[0];
        },

        // Depth of card `i` in the deck (0 = front). -1 if not present.
        depth(i) {
            return this.order.indexOf(i);
        },

        // Only the front card and the few peeking behind it render.
        isVisible(i) {
            return this.depth(i) < 3;
        },

        frontLabel() {
            // 1-based position of the front card within the deck.
            const pos = this.count ? (this.order[0] % this.count) + 1 : 0;
            return `Card ${pos} of ${this.count}`;
        },

        // Per-card transform, computed in JS (never a spliced Tailwind class).
        cardStyle(i) {
            const d = this.depth(i);
            const clamped = Math.min(d, 2);

            if (d === 0) {
                // Front card carries the live drag offset + a subtle rotation
                // proportional to horizontal travel (skipped under reduced motion).
                const rotate = this.prefersReducedMotion() ? 0 : this.dragX * 0.05;
                return [
                    `transform: translate(${this.dragX}px, ${this.dragY}px) rotate(${rotate}deg) scale(1);`,
                    'opacity: 1;',
                    `z-index: ${this.count + 1};`,
                    'pointer-events: auto;',
                ].join(' ');
            }

            // Peeking cards: offset down, scaled down, faded; behind the front.
            const translateY = clamped * 16;
            const scale = 1 - clamped * 0.06;
            const opacity = 1 - clamped * 0.25;
            return [
                `transform: translate(0px, ${translateY}px) scale(${scale});`,
                `opacity: ${opacity};`,
                `z-index: ${this.count - d};`,
                'pointer-events: none;',
            ].join(' ');
        },

        // --- Pointer drag --------------------------------------------------

        startDrag(event) {
            // Pointer dragging is a motion enhancement only; reduced-motion users
            // rely on keys/buttons.
            if (this.prefersReducedMotion() || this._flinging) return;
            this.dragging = true;
            this._startX = event.clientX;
            this._startY = event.clientY;
            window.addEventListener('pointermove', this._onMove);
            window.addEventListener('pointerup', this._onUp);
            window.addEventListener('pointercancel', this._onUp);
        },

        _move(event) {
            if (!this.dragging) return;
            const x = event.clientX - this._startX;
            const y = event.clientY - this._startY;
            // Batch reactive writes into a frame to avoid thrashing on fast moves.
            if (this._raf !== null) return;
            this._raf = requestAnimationFrame(() => {
                this.dragX = x;
                this.dragY = y;
                this._raf = null;
            });
        },

        _up() {
            if (!this.dragging) return;
            this.dragging = false;
            this._detach();
            if (this._raf !== null) {
                cancelAnimationFrame(this._raf);
                this._raf = null;
            }

            // Past the threshold in either direction → fling it away.
            if (Math.abs(this.dragX) > this.threshold) {
                this._fling(this.dragX > 0 ? 1 : -1);
            } else {
                // Spring back home.
                this.dragX = 0;
                this.dragY = 0;
            }
        },

        // Remove the window-level drag listeners (idempotent).
        _detach() {
            window.removeEventListener('pointermove', this._onMove);
            window.removeEventListener('pointerup', this._onUp);
            window.removeEventListener('pointercancel', this._onUp);
        },

        // --- Dismissal -----------------------------------------------------

        // Animate the front card off-screen in `dir` (1 = right, -1 = left),
        // then recycle it. Under reduced motion, jump straight to the recycle.
        _fling(dir) {
            if (this.prefersReducedMotion()) {
                this._recycle();
                return;
            }
            this._flinging = true;
            // Push it well past the viewport edge; the motion-safe transition on
            // the card animates the move.
            this.dragX = dir * 600;
            this.dragY = this.dragY + 40;
            window.setTimeout(() => {
                this._recycle();
                this._flinging = false;
            }, 300);
        },

        // Move the front card to the back and reset the drag offset.
        _recycle() {
            if (this.order.length >= 2 && (this.loop || this.order.length > 1)) {
                const front = this.order.shift();
                if (this.loop) {
                    this.order.push(front);
                }
            }
            this.dragX = 0;
            this.dragY = 0;
        },

        // Public dismiss used by keys and buttons. `dir` controls the visual
        // fling direction (and is harmless under reduced motion).
        dismiss(dir = 1) {
            if (this._flinging || this.order.length < 2) {
                // Still allow a non-looping deck's final advance to do nothing.
                if (this.order.length < 2) return;
            }
            this._fling(dir >= 0 ? 1 : -1);
        },
    }));
});

Ownership & lifecycle

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