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.5
Stability
stable
License
MIT
Related
Draggable Card Stack
Orbit Card Stack

Layered Stack — A card-deck with depth: the front card is fully visible while cards behind peek out (offset, scaled down, slightly faded). Clicking the front card or the Next control sends it to the back and cycles the next card forward with a motion-safe transition.

Preview

Discovery

Understand the problem space

Design

Shape the experience

Build

Ship it incrementally

Iterate

Learn and refine

Discovery Design Build Iterate

previews.components.layered-stack.default.blade.php Blade
<x-ui.layered-stack class="w-full max-w-sm" />

Installation

terminal
php artisan ui:add layered-stack

Note

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

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

Registry contract

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

layered-stack.md
# Brok UI: Layered Stack (`layered-stack`)

A card-deck with depth: the front card is fully visible while cards behind peek out (offset, scaled down, slightly faded). Clicking the front card or the Next control sends it to the back and cycles the next card forward with a motion-safe transition.

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

## Install

```bash
php artisan ui:add layered-stack
```

## Usage

```blade
<x-ui.layered-stack class="w-full max-w-sm" />
```

## Props

- `items` (array, default `[]`) — Cards in the deck; each needs a title, with optional subtitle and a background utility class.
- `visible` (int, default `3`) — How many layers deep, including the front card, stay visible in the peeking stack.
- `peek` (string, default `14px`) — Vertical offset between each visible layer, as a CSS length.
- `scaleStep` (float, default `0.05`) — Scale reduction applied per layer of depth.

## Use when

- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Showing a small deck of cards with visible depth (peeking layers behind the front card) that advances with a simple click or a Next control, without needing drag gestures.
- A step-by-step reveal (a process, a set of steps) where clicking the top card is the natural way to move to the next one.

## Avoid when

- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- Users should be able to drag/flick the front card away, or need explicit Skip/Like actions; use draggable-card-stack.
- The cards should orbit a centre point instead of stacking depth-wise; 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:layered-stack>` tag (or `<x-ui.layered-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/layered-stack
- Registry JSON (files, props, contract): https://brokui.dev/r/open/layered-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
items array [] Cards in the deck; each needs a title, with optional subtitle and a background utility class.
visible int 3 How many layers deep, including the front card, stay visible in the peeking stack.
peek string 14px Vertical offset between each visible layer, as a CSS length.
scaleStep float 0.05 Scale reduction applied per layer of depth.

Slots

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

Data slots

Stable hooks for CSS overrides and browser tests.

layered-stack

Behavior

  • The front card is fully visible; cards behind it peek out, offset down by peek, scaled down by scaleStep per step, and faded, up to visible layers deep.
  • Clicking the front card, or calling next(), sends it to the back and cycles the next card forward with wrap-around; only cards within the visible window keep a depth offset.
  • Reduced-motion users get an instant reorder with no animated transition; the deck stays fully operable.
  • 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.
  • Showing a small deck of cards with visible depth (peeking layers behind the front card) that advances with a simple click or a Next control, without needing drag gestures.
  • A step-by-step reveal (a process, a set of steps) where clicking the top card is the natural way to move to the next one.

Avoid when

  • Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
  • Users should be able to drag/flick the front card away, or need explicit Skip/Like actions; use draggable-card-stack.
  • The cards should orbit a centre point instead of stacking depth-wise; 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
Theming hooks
layered-stack

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
native
Focus
native
  • A polite live region announces the current front card's title; only the front card is interactive, and a full-card button provides a large, clearly labelled Next-card hit target.
  • Each card is role=group, and only the front card reports aria-hidden=false.
  • 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="layered-stack-{{ $record->id }}">
    <x-ui.layered-stack class="w-full max-w-sm" />
</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:layered-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/layered-stack.blade.php Blade
@props([
    'items' => [],
    'visible' => 3,
    'peek' => '14px',
    'scaleStep' => 0.05,
])

@php
    // Built-in demo set when no items are supplied. Each item: title/subtitle +
    // a semantic-token background utility (gradient or solid).
    $items = ! empty($items) ? $items : [
        ['title' => 'Discovery', 'subtitle' => 'Understand the problem space', 'bg' => 'bg-gradient-to-br from-chart-1 to-chart-4 text-primary-foreground'],
        ['title' => 'Design', 'subtitle' => 'Shape the experience', 'bg' => 'bg-gradient-to-br from-chart-2 to-chart-1 text-primary-foreground'],
        ['title' => 'Build', 'subtitle' => 'Ship it incrementally', 'bg' => 'bg-gradient-to-br from-chart-3 to-chart-5 text-primary-foreground'],
        ['title' => 'Iterate', 'subtitle' => 'Learn and refine', 'bg' => 'bg-gradient-to-br from-chart-5 to-chart-4 text-primary-foreground'],
    ];

    // Numeric peek offset (px) for the JS depth calc.
    $peekPx = (int) preg_replace('/[^0-9.]/', '', (string) $peek);

    $config = [
        'count' => count($items),
        'visible' => (int) $visible,
        'peek' => $peekPx,
        'scaleStep' => (float) $scaleStep,
    ];
@endphp

<div
    x-data="uiLayeredStack(@js($config))"
    data-slot="layered-stack"
    {{ $attributes->merge(['class' => 'relative']) }}
>
    {{-- Sized spacer so the absolutely-positioned cards have a footprint that
         accounts for the peeking layers below the front card. --}}
    <div class="relative" style="padding-bottom: calc(({{ (int) $visible }} - 1) * {{ $peek }});">
        <div class="relative aspect-[8/5]">
            @foreach ($items as $i => $item)
                <div
                    x-show="isVisible({{ $i }})"
                    :style="cardStyle({{ $i }})"
                    @class([
                        'absolute inset-0 flex flex-col justify-end gap-1 rounded-lg border border-border p-6 shadow-lg select-none',
                        'origin-top will-change-transform',
                        'motion-safe:transition-[transform,opacity] motion-safe:duration-300 motion-safe:ease-out',
                        $item['bg'] ?? 'bg-card text-card-foreground',
                    ])
                    @if ($i === 0) x-ref="front" @endif
                    role="group"
                    :aria-hidden="depth({{ $i }}) === 0 ? 'false' : 'true'"
                >
                    <p class="text-lg font-semibold leading-tight">{{ $item['title'] ?? '' }}</p>
                    @if (! empty($item['subtitle']))
                        <p class="text-sm opacity-90">{{ $item['subtitle'] }}</p>
                    @endif
                </div>
            @endforeach

            {{-- Tapping the front card advances the deck. It overlays only the
                 front card (pointer-events follow depth via cardStyle). --}}
            <button
                type="button"
                @click="next()"
                class="absolute inset-0 z-10 cursor-pointer rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                aria-label="Next card"
            ></button>
        </div>
    </div>

    {{-- Live region announcing the current front card for assistive tech. --}}
    <p class="sr-only" aria-live="polite">
        @foreach ($items as $i => $item)
            <span x-show="frontIndex() === {{ $i }}">{{ $item['title'] ?? '' }}</span>
        @endforeach
    </p>
</div>
resources/js/ui/layered-stack.js JS
/**
 * Layered stack behavior (spec §19 behavior layer).
 *
 * Renders a card deck with depth. The `order` array holds card indices,
 * front-most first; each card's depth (its position in `order`) drives the
 * translateY / scale / opacity / z-index applied via :style in the template.
 * Advancing moves the front card to the back, cycling the next card forward
 * with wrap-around (infinite).
 *
 * Motion is gated: the reorder transition is motion-safe (a Tailwind
 * `motion-safe:transition` utility on each card). Reduced-motion users get an
 * INSTANT reorder — no animated transition — and the deck stays fully operable
 * and readable.
 *
 * 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('uiLayeredStack', (config = {}) => ({
        count: config.count ?? 0,
        visible: config.visible ?? 3,
        peek: config.peek ?? 14,
        scaleStep: config.scaleStep ?? 0.05,
        order: [],

        init() {
            this.order = Array.from({ length: this.count }, (_, i) => i);
        },

        // The front card (depth 0) is the active one whose label is announced.
        frontIndex() {
            return this.order[0];
        },

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

        // Cards deeper than `visible - 1` are hidden behind the deck.
        isVisible(i) {
            return this.depth(i) < this.visible;
        },

        // Per-card transform driven purely by its depth — JS-computed, not a
        // Tailwind arbitrary calc.
        cardStyle(i) {
            const d = this.depth(i);
            const clamped = Math.min(d, this.visible - 1);
            const translate = clamped * this.peek;
            const scale = 1 - clamped * this.scaleStep;
            const opacity = d >= this.visible ? 0 : (d === 0 ? 1 : 1 - clamped * 0.18);
            const z = this.count - d;
            // Scale about the bottom edge so a deeper card keeps its full
            // `peek` below the front card (scaling about the centre would eat
            // the offset and hide the deck).
            return {
                transform: `translateY(${translate}px) scale(${scale})`,
                transformOrigin: 'bottom center',
                opacity,
                zIndex: z,
                pointerEvents: d === 0 ? 'auto' : 'none',
            };
        },

        // Send the front card to the back; the next card cycles forward.
        next() {
            if (this.order.length < 2) return;
            const front = this.order.shift();
            this.order.push(front);
        },
    }));
});

Ownership & lifecycle

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