Carousels
Scrolling card carousels — a flat slide-and-snap track, a 3D perspective coverflow, a rotating 3D ring and a centre-emphasis phone-screen coverflow.
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
<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
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:
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.
-
resources/views/components/ui/scrollable-card-stack.blade.php -
resources/js/ui/scrollable-card-stack.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: 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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-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="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.
@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>
/**
* 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