Card Stack
Stacked card decks — a click-to-cycle layered deck, a pointer-draggable swipe deck, and an orbiting 3D ring.
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.
<div class="w-full flex justify-center py-6">
<x-ui.draggable-card-stack />
</div>
Installation
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:
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.
-
resources/views/components/ui/draggable-card-stack.blade.php -
resources/js/ui/draggable-card-stack.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: 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.
# 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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, 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 underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<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.
<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.
@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"
>← {{ __('Drag or flick') }} →</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>
/**
* 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