Skip to content
Brok UI

Loading…

No results

Carousels

Open source

Scrolling card carousels — a flat slide-and-snap track, a 3D perspective coverflow, a rotating 3D ring and a centre-emphasis phone-screen coverflow.

Version
v1.1.1
Stability
stable
License
MIT
Related
Carousel
Cylinder Carousel
Scrollable Card Stack
Progressive Carousel
Phone Showcase
2 of 6

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.

/

previews.components.card-carousel-3d.default.blade.php Blade
<div class="mx-auto w-full max-w-2xl">
    <x-ui.card-carousel-3d />
</div>

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/card-carousel-3d.blade.php
  • js resources/js/ui/card-carousel-3d.js
Registry dependencies
carousel
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.

card-carousel-3d.md
# 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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

card-carousel-3d card-carousel-3d-item card-carousel-3d-next card-carousel-3d-prev

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

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.
  • 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
root card-carousel-3d-item card-carousel-3d-prev card-carousel-3d-next
Theming hooks
card-carousel-3d card-carousel-3d-item card-carousel-3d-next card-carousel-3d-prev

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-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="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.

resources/views/components/ui/card-carousel-3d.blade.php Blade
@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>
resources/js/ui/card-carousel-3d.js JS
/**
 * 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