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.
3D Card Carousel — A 3D perspective coverflow carousel: cards sit on a horizontal arc, the centered card faces front while side cards rotate on the Y axis, recede in Z, and dim. Prev/Next buttons, drag/swipe, and keyboard arrows rotate the looping ring. Reduced-motion and RTL aware.
Preview
Discover
Immersive depth
Cards arc through space with real perspective and Y-axis rotation.
Navigate
Glide either way
Drag, swipe, tap the arrows, or use the keyboard to spin the ring.
Focus
Centred clarity
The active card faces front while neighbours recede and dim.
Loop
Endless rotation
The carousel wraps seamlessly from the last card back to the first.
Adapt
Motion aware
Honours reduced-motion preferences and mirrors under right-to-left.
/
<div class="mx-auto w-full max-w-2xl">
<x-ui.card-carousel-3d />
</div>
Installation
php artisan ui:add card-carousel-3d
Note
This component ships an Alpine behavior module at
resources/js/ui/card-carousel-3d.js. Import it once from your bundle so it registers on alpine:init:
import './card-carousel-3d.js';
Registry contract
php artisan ui:add card-carousel-3d
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/card-carousel-3d.blade.php -
resources/js/ui/card-carousel-3d.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: 3D Card Carousel (`card-carousel-3d`)
A 3D perspective coverflow carousel: cards sit on a horizontal arc, the centered card faces front while side cards rotate on the Y axis, recede in Z, and dim. Prev/Next buttons, drag/swipe, and keyboard arrows rotate the looping ring. Reduced-motion and RTL aware.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add card-carousel-3d
```
## Usage
```blade
<div class="mx-auto w-full max-w-2xl">
<x-ui.card-carousel-3d />
</div>
```
## Props
- `items` (array, default `[…]`) — Cards on the ring; each needs a title, with optional eyebrow and description.
- `spread` (int, default `220`) — Sideways spread, in pixels, between adjacent cards on the arc.
- `depth` (int, default `200`) — How far back, in pixels, each step away from the active card pushes it in Z.
- `tilt` (int, default `38`) — Y-axis rotation in degrees applied per step away from the active card.
- `visible` (int, default `2`) — Cards more than this many steps from the active one are hidden entirely.
## Use when
- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Presenting a small set of feature cards as an immersive 3D coverflow where the centred card faces the viewer and neighbours recede on an arc.
- Wanting drag, swipe, keyboard-arrow and button navigation over an endlessly looping ring of cards.
## Avoid when
- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- A ring that rotates around a full 360-degree cylinder is wanted instead of a flat arc; use carousel-cylinder.
- The set is a plain content slideshow without 3D depth; use carousel.
## Anti-patterns
- Heavy effects in forms or task-dense workflows
- Motion that interferes with reading or focus
## Rules
- Use the `<brok:card-carousel-3d>` tag (or `<x-ui.card-carousel-3d>`) 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/card-carousel-3d
- Registry JSON (files, props, contract): https://brokui.dev/r/open/card-carousel-3d.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 |
|---|---|---|---|
| items | array | […] | Cards on the ring; each needs a title, with optional eyebrow and description. |
| spread | int | 220 | Sideways spread, in pixels, between adjacent cards on the arc. |
| depth | int | 200 | How far back, in pixels, each step away from the active card pushes it in Z. |
| tilt | int | 38 | Y-axis rotation in degrees applied per step away from the active card. |
| visible | int | 2 | Cards more than this many steps from the active one are hidden entirely. |
Slots
default— Unused; cards are rendered from the items prop.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Cards sit on a horizontal arc: the active card faces front while cards to either side shift sideways, recede in Z, rotate on the Y axis and dim in proportion to their looping distance from the active index.
- Prev/Next buttons, pointer drag past a threshold, and the left/right arrow keys rotate the ring, which always wraps.
- A polite live region announces the current position as index / total.
- 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.
- Presenting a small set of feature cards as an immersive 3D coverflow where the centred card faces the viewer and neighbours recede on an arc.
- Wanting drag, swipe, keyboard-arrow and button navigation over an endlessly looping ring of cards.
Avoid when
- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- A ring that rotates around a full 360-degree cylinder is wanted instead of a flat arc; use carousel-cylinder.
- The set is a plain content slideshow without 3D depth; use carousel.
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
- ArrowLeft ArrowRight
- Focus
managed
- The root exposes role=group with aria-roledescription=carousel, each card is aria-roledescription=slide, and only the active card is focusable (others get tabindex=-1 and aria-hidden=true).
- Arrow-key direction and drag direction both follow reading order, so they swap automatically under RTL.
- 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="card-carousel-3d-{{ $record->id }}">
<div class="mx-auto w-full max-w-2xl">
<x-ui.card-carousel-3d />
</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 on the ring. Each: { title, description, eyebrow? }. Neutral defaults, no PII.
'items' => [
['eyebrow' => 'Discover', 'title' => 'Immersive depth', 'description' => 'Cards arc through space with real perspective and Y-axis rotation.'],
['eyebrow' => 'Navigate', 'title' => 'Glide either way', 'description' => 'Drag, swipe, tap the arrows, or use the keyboard to spin the ring.'],
['eyebrow' => 'Focus', 'title' => 'Centred clarity', 'description' => 'The active card faces front while neighbours recede and dim.'],
['eyebrow' => 'Loop', 'title' => 'Endless rotation', 'description' => 'The carousel wraps seamlessly from the last card back to the first.'],
['eyebrow' => 'Adapt', 'title' => 'Motion aware', 'description' => 'Honours reduced-motion preferences and mirrors under right-to-left.'],
],
// Sideways spread (px) between adjacent cards on the arc.
'spread' => 220,
// How far back (px, negative Z) each step from centre pushes a card.
'depth' => 200,
// Y rotation (deg) applied per step away from the active card.
'tilt' => 38,
// Cards further than this many steps from centre are hidden.
'visible' => 2,
])
@php
// Serialise tuning into the Alpine factory. transform/opacity geometry only — no colour.
$config = [
'count' => count($items),
'spread' => (int) $spread,
'depth' => (int) $depth,
'tilt' => (int) $tilt,
'visible' => (int) $visible,
];
@endphp
<div
x-data="uiCardCarousel3d(@js($config))"
data-slot="card-carousel-3d"
role="group"
aria-roledescription="carousel"
{{ $attributes->merge(['class' => 'relative w-full select-none']) }}
>
{{-- 3D stage. perspective is geometry, not colour, so the inline style is fine. --}}
<div
class="relative h-72 w-full overflow-hidden focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring"
style="perspective: 1000px"
@pointerdown="onPointerDown($event)"
@keydown.arrow-left.prevent="onArrow('left')"
@keydown.arrow-right.prevent="onArrow('right')"
tabindex="0"
>
<div class="absolute inset-0" style="transform-style: preserve-3d">
@foreach ($items as $i => $item)
<article
data-slot="card-carousel-3d-item"
class="absolute start-1/2 top-1/2 flex h-64 w-64 -ms-32 -mt-32 flex-col justify-center gap-2 rounded-lg border border-border bg-card p-6 text-card-foreground shadow-lg transition-[transform,opacity] duration-500 ease-out will-change-transform motion-reduce:transition-none"
x-bind:style="cardStyle({{ $i }})"
x-bind:aria-hidden="active === {{ $i }} ? 'false' : 'true'"
x-bind:tabindex="active === {{ $i }} ? '0' : '-1'"
aria-roledescription="slide"
>
@isset($item['eyebrow'])
<p class="text-xs font-medium uppercase tracking-wide text-muted-foreground">{{ $item['eyebrow'] }}</p>
@endisset
<h3 class="text-lg font-semibold leading-tight">{{ $item['title'] }}</h3>
<p class="text-sm text-muted-foreground">{{ $item['description'] }}</p>
</article>
@endforeach
</div>
</div>
{{-- Controls: composes carousel.previous/next (endless ring, so disabled
is always false) against this component's own prev()/next(); logical
placement, arrows flip under dir="rtl" via the shared control's own
RTL handling. --}}
<div class="mt-4 flex items-center justify-center gap-3">
<x-ui.carousel.previous
data-slot="card-carousel-3d-prev"
aria-label="{{ __('Previous card') }}"
on-click="prev()"
disabled="false"
class="!static !h-10 !w-10 !rounded-md !border-input !shadow-none hover:!bg-accent hover:!text-accent-foreground"
/>
{{-- Live region announces the current position to assistive tech. --}}
<p class="min-w-16 text-center text-sm text-muted-foreground" aria-live="polite">
<span x-text="active + 1"></span> / <span x-text="count"></span>
</p>
<x-ui.carousel.next
data-slot="card-carousel-3d-next"
aria-label="{{ __('Next card') }}"
on-click="next()"
disabled="false"
class="!static !h-10 !w-10 !rounded-md !border-input !shadow-none hover:!bg-accent hover:!text-accent-foreground"
/>
</div>
</div>
/**
* 3D coverflow card carousel.
*
* Cards are laid out on a horizontal arc in 3D: the active card faces front,
* and each card to either side is shifted sideways (translateX), pushed back
* (translateZ), rotated on the Y axis, scaled down and dimmed in proportion to
* its (looping) distance from the active index. Cards beyond `visible` steps are
* hidden entirely. Prev/Next, drag/swipe and the arrow keys rotate the ring,
* which wraps in both directions.
*
* All geometry is emitted as inline `transform`/`opacity` (no spliced Tailwind
* classes, no colour). Drag listeners live on `window` only while dragging and
* are torn down on pointer-up and in destroy(); no timers or RAF are retained.
*
* 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('uiCardCarousel3d', (config = {}) => ({
active: 0,
count: config.count ?? 0,
spread: config.spread ?? 220,
depth: config.depth ?? 200,
tilt: config.tilt ?? 38,
visible: config.visible ?? 2,
// Pointer-drag state.
dragging: false,
startX: 0,
// Bound handlers, kept so the same references can be removed later.
_onMove: null,
_onUp: null,
destroy() {
this._teardownDrag();
},
/**
* Shortest signed distance from card `i` to the active index around the
* ring, so the carousel loops symmetrically (e.g. with 5 cards the step
* from 0 to 4 is -1, not +4).
*/
offset(i) {
let d = i - this.active;
const n = this.count;
if (n > 0) {
d = ((d % n) + n) % n; // 0..n-1
if (d > n / 2) d -= n; // fold to the shorter arc
}
return d;
},
/** Inline transform for card `i`, by its looping offset. */
cardStyle(i) {
const d = this.offset(i);
const abs = Math.abs(d);
// Hide cards past the visible window without leaving them focusable.
if (abs > this.visible) {
return 'opacity: 0; pointer-events: none; transform: translateZ(' + -this.depth * (this.visible + 1) + 'px);';
}
const x = d * this.spread;
const z = -abs * this.depth;
const rotateY = -d * this.tilt;
const scale = Math.max(0.6, 1 - abs * 0.12);
// Centre card sits on top of the stack and stays interactive.
const zIndex = this.count - abs;
const pointer = abs === 0 ? 'auto' : 'none';
return (
'transform: translateX(' +
x +
'px) translateZ(' +
z +
'px) rotateY(' +
rotateY +
'deg) scale(' +
scale +
');' +
' opacity: 1;' +
' z-index: ' +
zIndex +
';' +
' pointer-events: ' +
pointer +
';'
);
},
next() {
if (this.count === 0) return;
this.active = (this.active + 1) % this.count;
},
prev() {
if (this.count === 0) return;
this.active = (this.active - 1 + this.count) % this.count;
},
_isRtl() {
return getComputedStyle(this.$el).direction === 'rtl';
},
/** ArrowLeft/ArrowRight follow reading order, so they swap under RTL. */
onArrow(dir) {
const rtl = this._isRtl();
if (dir === 'right') {
rtl ? this.prev() : this.next();
} else {
rtl ? this.next() : this.prev();
}
},
onPointerDown(e) {
// Primary button / touch / pen only.
if (e.button !== undefined && e.button !== 0) return;
this.dragging = true;
this.startX = e.clientX;
this._onMove = (ev) => this._onPointerMove(ev);
this._onUp = (ev) => this._onPointerUp(ev);
window.addEventListener('pointermove', this._onMove);
window.addEventListener('pointerup', this._onUp);
},
_onPointerMove(e) {
if (!this.dragging) return;
const dx = e.clientX - this.startX;
// One step per threshold; reading-order aware so drag direction is natural in RTL.
const threshold = 60;
if (Math.abs(dx) < threshold) return;
const rtl = this._isRtl();
const draggedLeft = dx < 0;
(draggedLeft !== rtl) ? this.next() : this.prev();
this.startX = e.clientX; // re-anchor so a long drag advances multiple cards
},
_onPointerUp() {
this.dragging = false;
this._teardownDrag();
},
_teardownDrag() {
if (this._onMove) {
window.removeEventListener('pointermove', this._onMove);
this._onMove = null;
}
if (this._onUp) {
window.removeEventListener('pointerup', this._onUp);
this._onUp = null;
}
},
}));
});
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