Expandable Card
A stack of trigger rows that expand into a centered modal panel over a blurred backdrop, revealing each row's full detail. Escape / backdrop-click closes, with focus trapping and restore.
Preview
Realtime Sync
Infrastructure / Edge replication
Updated 2h ago
Changes propagate to every connected client within ~50ms via regional edge nodes, with automatic conflict resolution and offline replay so nothing is lost on reconnect.
Threat Guard
Security / Anomaly detection
Enabled
Continuously scores sign-in attempts against device, location and behavioural signals, stepping up to MFA only when risk crosses your configured threshold.
Build Pipeline
Automation / Parallel CI
3 min average
Fans every commit out across a parallel matrix, caches dependencies between runs and ships a preview deployment the moment all required checks go green.
<div class="mx-auto w-full max-w-md p-6">
<x-ui.expandable-card />
</div>
Installation
php artisan ui:add expandable-card
Note
This component ships an Alpine behavior module at
resources/js/ui/expandable-card.js. Import it once from your bundle so it registers on alpine:init:
import './expandable-card.js';
Registry contract
php artisan ui:add expandable-card
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/expandable-card.blade.php -
resources/js/ui/expandable-card.js
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: Expandable Card (`expandable-card`)
A stack of trigger rows that expand into a centered modal panel over a blurred backdrop, revealing each row's full detail. Escape / backdrop-click closes, with focus trapping and restore.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add expandable-card
```
## Usage
```blade
<div class="mx-auto w-full max-w-md p-6">
<x-ui.expandable-card />
</div>
```
## Props
- `morph` (bool, default `false`)
- `items` (array, default `[…]`)
## Use when
- Use when self-contained content objects benefit from a recognizable card-like presentation with local actions or metadata.
- Letting a compact list of items each open into a full detail panel without leaving the page.
- Showing a portfolio, team or feature list where the detail deserves a focused modal view.
## Avoid when
- Do not use card widgets where a simpler list, table, or plain text block would make comparison faster.
- Showing detail that fits inline; accordion or collapsible keeps the reader in flow.
- Opening a form or a destructive confirmation; dialog and alert-dialog own those semantics.
## Anti-patterns
- Using cards for dense cross-item comparison
## Rules
- Use the `<brok:expandable-card>` tag (or `<x-ui.expandable-card>`) 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/expandable-card
- Registry JSON (files, props, contract): https://brokui.dev/r/open/expandable-card.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
{{-- Shared-layout morph: the clicked row animates into the panel on open and
back onto the row on close (the dialog's onOpen / beforeLeave hooks). --}}
<div class="mx-auto w-full max-w-md p-6">
<x-ui.expandable-card morph />
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| morph | bool | false | |
| items | array | […] |
Slots
default—
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Each row is a trigger; activating it opens a centred panel over a blurred backdrop showing that item's full detail.
- Escape, the close button and a backdrop click close the panel; morph animates the row into the panel where motion is allowed.
- Focus is trapped inside the open panel and restored to the trigger on close.
- The panel is the dialog primitive (composed with padding="none"; transition="none" when morph is on): scroll lock, inert sweep, focus trap, Escape and focus return come from dialog's overlay-lifecycle. The FLIP runs through the dialog's onOpen hook (panel out of the row) and beforeLeave hook (panel back onto the row before it unmounts), so open and close both morph.
- 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
Present a self-contained content object with local context.
Use when
- Use when self-contained content objects benefit from a recognizable card-like presentation with local actions or metadata.
- Letting a compact list of items each open into a full detail panel without leaving the page.
- Showing a portfolio, team or feature list where the detail deserves a focused modal view.
Avoid when
- Do not use card widgets where a simpler list, table, or plain text block would make comparison faster.
- Showing detail that fits inline; accordion or collapsible keeps the reader in flow.
- Opening a form or a destructive confirmation; dialog and alert-dialog own those semantics.
Use instead
- List for compact scanning
- Table for exact comparison
Anti-patterns
- Using cards for dense cross-item comparison
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- Tab Escape
- Focus
native
- Triggers carry aria-haspopup="dialog" and aria-controls, and the panel is the dialog primitive's role="dialog" with aria-modal, labelled by the current row's title.
- Tab is contained inside the panel and the close control carries a translated label.
- 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="expandable-card-{{ $record->id }}">
<div class="mx-auto w-full max-w-md p-6">
<x-ui.expandable-card />
</div>
</div>
Validation
Validation support: native. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:expandable-card
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([
// Opt-in shared-layout morph: when true, opening animates the clicked
// trigger row into the centered panel (dependency-free FLIP). Default false
// keeps the original opacity/scale transition. Reduced-motion safe.
'morph' => false,
// Each item: id, title, subtitle, description, details, metadata, icon.
// `icon` is one of the built-in glyph names below (defaults to 'layers').
'items' => [
[
'id' => 'sync',
'icon' => 'layers',
'title' => 'Realtime Sync',
'subtitle' => 'Infrastructure',
'description' => 'Edge replication',
'metadata' => 'Updated 2h ago',
'details' => 'Changes propagate to every connected client within ~50ms via regional edge nodes, with automatic conflict resolution and offline replay so nothing is lost on reconnect.',
],
[
'id' => 'guard',
'icon' => 'shield',
'title' => 'Threat Guard',
'subtitle' => 'Security',
'description' => 'Anomaly detection',
'metadata' => 'Enabled',
'details' => 'Continuously scores sign-in attempts against device, location and behavioural signals, stepping up to MFA only when risk crosses your configured threshold.',
],
[
'id' => 'pipeline',
'icon' => 'bolt',
'title' => 'Build Pipeline',
'subtitle' => 'Automation',
'description' => 'Parallel CI',
'metadata' => '3 min average',
'details' => 'Fans every commit out across a parallel matrix, caches dependencies between runs and ships a preview deployment the moment all required checks go green.',
],
],
])
@php
// Outline glyphs (lucide-style), stroke = currentColor so they follow tokens.
$glyphs = [
'layers' => 'M12 2 2 7l10 5 10-5-10-5ZM2 17l10 5 10-5M2 12l10 5 10-5',
'shield' => 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z',
'bolt' => 'M13 2 3 14h9l-1 8 10-12h-9l1-8Z',
];
$items = array_values($items);
// Panel ids are per instance: two expandable-cards on one page shipped
// colliding `exp-panel-<item id>` ids before.
$uid = 'expandable-card-'.\Illuminate\Support\Str::random(8);
@endphp
{{-- Expandable Card: a list of trigger rows that expand into a centered modal
panel over a blurred backdrop. The modal is <x-ui.dialog>: it owns the
teleported portal, scroll lock, inert sweep, focus trap, Escape and focus
return. This item only owns which row is current and, with `morph`, the
shared-layout FLIP: `onOpen` animates the panel out of the clicked row and
`beforeLeave` animates it back before the dialog unmounts the panel
(`transition="none"` hands the panel motion to the FLIP; the overlay keeps
the dialog's fade). --}}
<div
data-slot="expandable-card"
x-data="uiExpandableCard({
morph: {{ $morph ? 'true' : 'false' }},
panel: {{ \Illuminate\Support\Js::from($uid.'-panel') }},
labels: {{ \Illuminate\Support\Js::from(array_column(array_map(static fn (array $item): array => ['id' => (string) $item['id'], 'title' => __($item['title'])], $items), 'title', 'id')) }},
})"
x-ref="root"
{{ $attributes->merge(['class' => 'relative w-full']) }}
>
<x-ui.dialog hooks="{ beforeLeave: () => flipOut(), onOpen: () => flipIn() }">
{{-- Trigger rows. Each row is a dialog trigger for the shared panel: it
marks itself current, then opens the dialog. --}}
<ul role="list" class="flex w-full flex-col gap-3">
@foreach ($items as $item)
<li>
<button
type="button"
x-on:click="select('{{ $item['id'] }}', $event.currentTarget); show()"
aria-haspopup="dialog"
:aria-expanded="open && current === '{{ $item['id'] }}' ? 'true' : 'false'"
aria-controls="{{ $uid }}-panel"
class="flex w-full items-center gap-4 rounded-md border border-border bg-card p-3 text-start shadow-sm transition-transform duration-150 ease-out hover:scale-[1.01] focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-[length:var(--ring-offset-width)] focus-visible:ring-offset-background md:p-4"
>
<span class="flex size-11 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-5" aria-hidden="true">
<path d="{{ $glyphs[$item['icon'] ?? 'layers'] ?? $glyphs['layers'] }}" />
</svg>
</span>
<span class="flex min-w-0 flex-col gap-0.5">
<span class="font-medium text-foreground">{{ __($item['title']) }}</span>
<span class="truncate text-xs text-muted-foreground">{{ __($item['subtitle']) }} / {{ __($item['description']) }}</span>
<span class="text-xs text-muted-foreground">{{ __($item['metadata']) }}</span>
</span>
</button>
</li>
@endforeach
</ul>
{{-- One panel for every row; the current row's detail is shown inside
it. The panel is labelled by the current row's title. --}}
<x-ui.dialog.content
id="{{ $uid }}-panel"
size="lg"
overlay="blur"
padding="none"
:transition="$morph ? 'none' : 'default'"
x-bind:aria-label="label()"
data-expandable-card-panel
>
<x-ui.dialog.close />
@foreach ($items as $item)
<div
x-show="current === '{{ $item['id'] }}'"
data-panel
data-id="{{ $item['id'] }}"
class="flex flex-col gap-4 p-6"
>
<div class="flex items-start gap-4 pe-8">
<span class="flex size-11 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-5" aria-hidden="true">
<path d="{{ $glyphs[$item['icon'] ?? 'layers'] ?? $glyphs['layers'] }}" />
</svg>
</span>
<div class="flex min-w-0 flex-col gap-0.5">
<p class="font-medium text-foreground">{{ __($item['title']) }}</p>
<p class="text-sm text-muted-foreground">{{ __($item['subtitle']) }} / {{ __($item['description']) }}</p>
<p class="text-xs text-muted-foreground">{{ __($item['metadata']) }}</p>
</div>
</div>
<p class="text-sm text-muted-foreground">{{ __($item['details']) }}</p>
</div>
@endforeach
</x-ui.dialog.content>
</x-ui.dialog>
</div>
/**
* Expandable Card behaviour (list row → centered modal panel).
*
* A stack of trigger rows; clicking one opens a centered panel over a blurred
* backdrop that reveals the row's full detail.
*
* The modal is the dialog item (`<x-ui.dialog>` in the Blade): it owns the
* teleported portal, scroll lock, inert sweep, focus trap, Escape, backdrop
* click and focus return. This component only tracks which row is current
* and, with `morph: true`, the shared-layout FLIP:
* - Default (morph: false): the dialog's own opacity/scale transition.
* - Opt-in (morph: true): the panel is rendered with `transition="none"` and
* the dialog's `onOpen` hook runs `flipIn()` — measure the clicked row and
* the panel, map the panel onto the row, release on the next frame. The
* dialog's `beforeLeave` hook awaits `flipOut()`, which animates the panel
* back onto the row before the dialog unmounts it; the dialog fades its
* overlay meanwhile. Only numeric transform/opacity are written to `style`.
*
* reduced-motion: when the user prefers reduced motion the FLIP is skipped and
* the panel appears/disappears instantly.
*
* destroy() removes any pending rAF / timer / transitionend listener so
* unmounting mid-animation can't leak. The dialog releases its own locks.
*
* Self-registers on `alpine:init` so import order does not matter.
*
* Note: this file is plain JS served by the registry — no Blade syntax allowed.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiExpandableCard', (options = {}) => ({
morph: Boolean(options.morph),
// DOM id of the shared dialog panel (teleported to <body>, so it is
// looked up by id rather than through $refs).
panelId: typeof options.panel === 'string' ? options.panel : '',
// Row id → accessible title, so the panel can label itself by the
// current row.
labels: options.labels && typeof options.labels === 'object' ? options.labels : {},
current: null,
_trigger: null,
_raf: null,
_timer: null,
_onEnd: null,
/** Mark a row current; the row then opens the dialog itself. */
select(id, triggerEl) {
this._trigger = triggerEl || document.activeElement;
this.current = id;
},
label() {
return this.current ? this.labels[this.current] || '' : '';
},
panel() {
return this.panelId ? document.getElementById(this.panelId) : null;
},
/** Dialog `onOpen` hook: the panel is laid out and focused. */
flipIn() {
const panel = this.panel();
if (!panel || !this._shouldMorph()) return;
this._flipIn(panel);
},
/**
* Dialog `beforeLeave` hook: resolves once the panel has animated back
* onto its row (or at once when there is nothing to animate), after
* which the dialog unmounts the panel.
*/
flipOut() {
const panel = this.panel();
if (!panel || !this._shouldMorph()) {
return Promise.resolve();
}
return new Promise((resolve) => this._flipOut(panel, resolve));
},
_shouldMorph() {
if (!this.morph) return false;
return !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
},
// Map `panel` onto the trigger rect, then release to its natural place.
_flipIn(panel) {
const trigger = this._trigger;
if (!trigger || typeof trigger.getBoundingClientRect !== 'function') return;
this._clearAnim(panel);
const from = trigger.getBoundingClientRect();
const to = panel.getBoundingClientRect();
if (!to.width || !to.height) return;
const sx = from.width / to.width;
const sy = from.height / to.height;
const dx = from.left + from.width / 2 - (to.left + to.width / 2);
const dy = from.top + from.height / 2 - (to.top + to.height / 2);
panel.style.transformOrigin = 'center center';
panel.style.transition = 'none';
panel.style.transform = `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`;
panel.style.opacity = '0.6';
this._raf = window.requestAnimationFrame(() => {
this._raf = window.requestAnimationFrame(() => {
panel.style.transition = 'transform 220ms ease-out, opacity 160ms ease-out';
panel.style.transform = 'translate(0px, 0px) scale(1, 1)';
panel.style.opacity = '1';
this._onTransitionEnd(panel, () => this._clearAnim(panel));
});
});
},
// Animate `panel` back toward the trigger rect, then hand back.
_flipOut(panel, done) {
const trigger = this._trigger;
this._clearAnim(panel);
if (!trigger || typeof trigger.getBoundingClientRect !== 'function') {
done();
return;
}
const from = trigger.getBoundingClientRect();
const to = panel.getBoundingClientRect();
if (!to.width || !to.height) {
done();
return;
}
const sx = from.width / to.width;
const sy = from.height / to.height;
const dx = from.left + from.width / 2 - (to.left + to.width / 2);
const dy = from.top + from.height / 2 - (to.top + to.height / 2);
panel.style.transformOrigin = 'center center';
panel.style.transition = 'transform 200ms ease-in, opacity 160ms ease-in';
this._raf = window.requestAnimationFrame(() => {
panel.style.transform = `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`;
panel.style.opacity = '0';
this._onTransitionEnd(panel, () => {
this._clearAnim(panel);
done();
});
});
},
_onTransitionEnd(panel, cb) {
let called = false;
const finish = () => {
if (called) return;
called = true;
panel.removeEventListener('transitionend', handler);
this._onEnd = null;
if (this._timer) {
window.clearTimeout(this._timer);
this._timer = null;
}
cb();
};
const handler = (event) => {
if (event.target === panel && event.propertyName === 'transform') {
finish();
}
};
this._onEnd = { panel, handler };
panel.addEventListener('transitionend', handler);
// Safety net if transitionend never fires.
this._timer = window.setTimeout(finish, 400);
},
_clearAnim(panel) {
if (this._raf) {
window.cancelAnimationFrame(this._raf);
this._raf = null;
}
if (this._timer) {
window.clearTimeout(this._timer);
this._timer = null;
}
if (this._onEnd) {
this._onEnd.panel.removeEventListener('transitionend', this._onEnd.handler);
this._onEnd = null;
}
if (panel) {
panel.style.transition = '';
panel.style.transform = '';
panel.style.opacity = '';
panel.style.transformOrigin = '';
}
},
destroy() {
this._clearAnim(this.panel());
this.current = null;
this._trigger = null;
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.3.2 - 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