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
3D Card Carousel
Scrollable Card Stack
Progressive Carousel
Phone Showcase
3 of 6

Cylinder Carousel — A CSS-3D rotating-ring carousel: cards are arranged around a vertical-axis cylinder (rotateY + translateZ) and the whole ring spins to bring the selected card to the front. The front card faces the viewer while cards curving away dim and scale down with depth. Prev/Next buttons, dots, drag/swipe to spin, and arrow-key navigation. Pure CSS 3D transforms (no WebGL/three.js). Reduced-motion and RTL aware.

Preview

Rotate

A turning cylinder

Cards wrap around a vertical-axis ring; the whole cylinder spins to bring one to the front.

Depth

Curving away

Cards facing the viewer stay crisp while those turning past the edge dim and recede.

Steer

Spin any way

Drag, swipe, tap the arrows, or use the arrow keys to rotate the ring.

Loop

Seamless wrap

The ring is endless — the last card flows straight back to the first.

Aware

Motion & RTL

Honours reduced-motion and mirrors its navigation under right-to-left.

Slide of

previews.components.carousel-cylinder.default.blade.php Blade
<div class="mx-auto flex h-80 w-full max-w-2xl items-center" style="perspective: 1100px">
    <x-ui.carousel-cylinder class="w-full" />
</div>

Installation

terminal
php artisan ui:add carousel-cylinder

Note

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

resources/js/ui/index.js JS
import './carousel-cylinder.js';

Registry contract

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

carousel-cylinder.md
# Brok UI: Cylinder Carousel (`carousel-cylinder`)

A CSS-3D rotating-ring carousel: cards are arranged around a vertical-axis cylinder (rotateY + translateZ) and the whole ring spins to bring the selected card to the front. The front card faces the viewer while cards curving away dim and scale down with depth. Prev/Next buttons, dots, drag/swipe to spin, and arrow-key navigation. Pure CSS 3D transforms (no WebGL/three.js). 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 carousel-cylinder
```

## Usage

```blade
<div class="mx-auto flex h-80 w-full max-w-2xl items-center" style="perspective: 1100px">
    <x-ui.carousel-cylinder class="w-full" />
</div>
```

## Props

- `items` (array, default `[…]`) — Slides arranged around the cylinder; each needs a title and description, with an optional eyebrow.
- `radius` (int, default `320`) — Cylinder radius in pixels: how far each card sits from the central axis.
- `perspective` (int, default `1100`) — Scene perspective in pixels; a smaller value gives stronger 3D foreshortening.

## Use when

- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Wanting a stronger, uninterrupted 360-degree rotation effect than a flat coverflow arc gives, such as a showcase of logos or product shots arranged around a ring.
- Needing drag-to-spin momentum where the ring keeps turning in the same direction rather than resetting.

## Avoid when

- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- A flatter arc-based 3D effect is enough; use card-carousel-3d, which is lighter and easier to read at a glance.
- 3D transforms are not wanted at all; use carousel.

## Anti-patterns

- Heavy effects in forms or task-dense workflows
- Motion that interferes with reading or focus

## Rules

- Use the `<brok:carousel-cylinder>` tag (or `<x-ui.carousel-cylinder>`) 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/carousel-cylinder
- Registry JSON (files, props, contract): https://brokui.dev/r/open/carousel-cylinder.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 […] Slides arranged around the cylinder; each needs a title and description, with an optional eyebrow.
radius int 320 Cylinder radius in pixels: how far each card sits from the central axis.
perspective int 1100 Scene perspective in pixels; a smaller value gives stronger 3D foreshortening.

Slots

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

Data slots

Stable hooks for CSS overrides and browser tests.

carousel-cylinder carousel-cylinder-dots carousel-cylinder-item carousel-cylinder-next carousel-cylinder-prev carousel-cylinder-ring

Behavior

  • Cards are placed around a vertical-axis cylinder with rotateY + translateZ baked per card, and the whole ring rotates to bring the selected card to the front; cards curving away from the viewer dim and scale down with depth.
  • Prev/Next, the dot row, pointer drag (which keeps spinning the ring in the drag direction rather than resetting) and the arrow keys all drive the rotation, snapping to the nearest card on release.
  • Pure CSS 3D transforms only, no WebGL or three.js.
  • 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.
  • Wanting a stronger, uninterrupted 360-degree rotation effect than a flat coverflow arc gives, such as a showcase of logos or product shots arranged around a ring.
  • Needing drag-to-spin momentum where the ring keeps turning in the same direction rather than resetting.

Avoid when

  • Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
  • A flatter arc-based 3D effect is enough; use card-carousel-3d, which is lighter and easier to read at a glance.
  • 3D transforms are not wanted at all; 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 carousel-cylinder-ring carousel-cylinder-item carousel-cylinder-prev carousel-cylinder-dots carousel-cylinder-next
Theming hooks
carousel-cylinder carousel-cylinder-dots carousel-cylinder-item carousel-cylinder-next carousel-cylinder-prev carousel-cylinder-ring

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
ArrowLeft ArrowRight
Focus
managed
  • The root is role=group aria-roledescription=carousel with a polite live region announcing the current slide position; only the active card is focusable.
  • Arrow-key and drag direction follow reading order, so they swap 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="carousel-cylinder-{{ $record->id }}">
    <div class="mx-auto flex h-80 w-full max-w-2xl items-center" style="perspective: 1100px">
        <x-ui.carousel-cylinder class="w-full" />
    </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/carousel-cylinder.blade.php Blade
@props([
    // Slides arranged around the cylinder. Each: { eyebrow?, title, description }.
    // Neutral demo content — no PII, no external images.
    'items' => [
        ['eyebrow' => 'Rotate', 'title' => 'A turning cylinder', 'description' => 'Cards wrap around a vertical-axis ring; the whole cylinder spins to bring one to the front.'],
        ['eyebrow' => 'Depth', 'title' => 'Curving away', 'description' => 'Cards facing the viewer stay crisp while those turning past the edge dim and recede.'],
        ['eyebrow' => 'Steer', 'title' => 'Spin any way', 'description' => 'Drag, swipe, tap the arrows, or use the arrow keys to rotate the ring.'],
        ['eyebrow' => 'Loop', 'title' => 'Seamless wrap', 'description' => 'The ring is endless — the last card flows straight back to the first.'],
        ['eyebrow' => 'Aware', 'title' => 'Motion & RTL', 'description' => 'Honours reduced-motion and mirrors its navigation under right-to-left.'],
    ],
    // Cylinder radius (px): how far each card sits from the central axis.
    'radius' => 320,
    // Scene perspective (px): smaller = stronger 3D foreshortening.
    'perspective' => 1100,
])

@php
    $count = count($items);
    // Angular step between adjacent cards on the ring.
    $step = $count > 0 ? 360 / $count : 0;

    // Geometry only — no colour. Fed to the Alpine factory for rotation maths.
    $config = [
        'count' => $count,
        'step' => $step,
        'radius' => (int) $radius,
    ];
@endphp

<div
    x-data="uiCarouselCylinder(@js($config))"
    data-slot="carousel-cylinder"
    role="group"
    aria-roledescription="carousel"
    aria-label="{{ __('Cylinder carousel') }}"
    {{ $attributes->merge(['class' => 'relative w-full select-none']) }}
>
    {{-- 3D scene. 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: {{ (int) $perspective }}px"
        @pointerdown="onPointerDown($event)"
        @keydown.arrow-left.prevent="onArrow('left')"
        @keydown.arrow-right.prevent="onArrow('right')"
        tabindex="0"
    >
        {{--
            The rotating ring. JS sets `--ring-angle` (deg); a fixed class consumes it
            via transform — no spliced arbitrary classes, so the JIT stays happy.
        --}}
        <div
            data-slot="carousel-cylinder-ring"
            class="absolute inset-0 transition-transform duration-700 ease-out will-change-transform motion-reduce:transition-none [transform-style:preserve-3d] [transform:translateZ(calc(var(--cyl-radius)*-1))_rotateY(var(--ring-angle))]"
            style="--cyl-radius: {{ (int) $radius }}px; --ring-angle: 0deg"
            x-ref="ring"
        >
            @foreach ($items as $i => $item)
                <article
                    data-slot="carousel-cylinder-item"
                    {{--
                        Static placement angle baked per card via a CSS var (--card-angle),
                        plus a depth fade var (--card-dim) that JS updates as the ring turns.
                    --}}
                    class="absolute start-1/2 top-1/2 flex h-60 w-72 -ms-36 -mt-28 flex-col justify-center gap-2 rounded-lg border border-border bg-card p-6 text-card-foreground shadow-lg transition-[opacity,transform] duration-700 ease-out will-change-[opacity,transform] motion-reduce:transition-none [transform:rotateY(var(--card-angle))_translateZ(var(--cyl-radius))] [backface-visibility:hidden]"
                    style="--card-angle: {{ $i * $step }}deg; --card-dim: 1"
                    x-bind:style="cardVars({{ $i }})"
                    x-bind:aria-hidden="active === {{ $i }} ? 'false' : 'true'"
                    x-bind:tabindex="active === {{ $i }} ? '0' : '-1'"
                    aria-roledescription="slide"
                    aria-label="{{ trim(($item['title'] ?? '')) !== '' ? $item['title'] : __('Slide :n', ['n' => $i + 1]) }}"
                >
                    @isset($item['eyebrow'])
                        <p class="text-xs font-medium uppercase tracking-wide text-muted-foreground">{{ $item['eyebrow'] }}</p>
                    @endisset
                    @isset($item['title'])
                        <h3 class="text-lg font-semibold leading-tight">{{ $item['title'] }}</h3>
                    @endisset
                    @isset($item['description'])
                        <p class="text-sm text-muted-foreground">{{ $item['description'] }}</p>
                    @endisset
                </article>
            @endforeach
        </div>
    </div>

    {{-- Controls: this is an endless ring (no boundary), so the arrows compose
         carousel.previous/next with disabled always false and their own
         inline (not overlay) sizing; the dot row composes carousel.dots
         against this component's own `active`/`goTo` names. Logical
         placement; arrows flip under dir="rtl" via the JS direction check. --}}
    <div class="mt-4 flex items-center justify-center gap-3">
        <x-ui.carousel.previous
            data-slot="carousel-cylinder-prev"
            disabled="false"
            class="!static !h-10 !w-10 !rounded-md !border-input !shadow-none hover:!bg-accent hover:!text-accent-foreground"
        />

        <x-ui.carousel.dots :count="count($items)" active="active" goTo="goTo" data-slot="carousel-cylinder-dots" />

        <x-ui.carousel.next
            data-slot="carousel-cylinder-next"
            disabled="false"
            class="!static !h-10 !w-10 !rounded-md !border-input !shadow-none hover:!bg-accent hover:!text-accent-foreground"
        />
    </div>

    {{-- Live region announces the current position to assistive tech. --}}
    <p class="sr-only" aria-live="polite">
        {{ __('Slide') }} <span x-text="active + 1"></span> {{ __('of') }} <span x-text="count"></span>
    </p>
</div>
resources/js/ui/carousel-cylinder.js JS
/**
 * CSS-3D rotating-ring (cylinder) carousel.
 *
 * Distinct from the linear `card-carousel-3d` coverflow: cards are arranged
 * around a vertical-axis CYLINDER — each card is placed with
 * `rotateY(i * step) translateZ(radius)` (baked in Blade via the `--card-angle`
 * CSS var), and the whole ring rotates with `rotateY(--ring-angle)` to bring the
 * selected card to the front. Cards curving away from the viewer dim and scale
 * down with depth; the front card faces the viewer and stays interactive.
 *
 * All motion is expressed through CSS custom properties (`--ring-angle`,
 * `--card-dim`, `--card-scale`) and inline `opacity` consumed by fixed classes —
 * no Tailwind classes are spliced from numbers, so the JIT scanner sees every
 * utility used.
 *
 * Drag listeners live on `window` only while dragging and are torn down on
 * pointer-up and in destroy(); a single rAF used for the live drag spin is
 * cancelled on teardown. No timers 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('uiCarouselCylinder', (config = {}) => ({
        active: 0,
        count: config.count ?? 0,
        step: config.step ?? 0, // degrees between adjacent cards
        radius: config.radius ?? 320,

        // Continuous ring angle (deg). Snaps to `-active * step` at rest; tracks
        // the finger mid-drag for a smooth physical spin.
        angle: 0,

        // Pointer-drag state.
        dragging: false,
        startX: 0,
        startAngle: 0,
        _raf: null,
        _onMove: null,
        _onUp: null,

        init() {
            this.angle = this._restAngle();
            this._apply();
        },

        destroy() {
            this._teardownDrag();
        },

        /** Resting ring angle that puts the active card at the front. */
        _restAngle() {
            return -this.active * this.step;
        },

        _isRtl() {
            return getComputedStyle(this.$el).direction === 'rtl';
        },

        /**
         * Per-card CSS vars driving the depth fade. `delta` is the smallest angle
         * (deg) between this card's current facing and the viewer (0 = front).
         * Front cards are bright and full size; cards turning away dim and shrink.
         */
        cardVars(i) {
            // Card i currently faces the viewer when angle + i*step ≡ 0 (mod 360).
            let facing = (this.angle + i * this.step) % 360;
            if (facing > 180) facing -= 360;
            if (facing < -180) facing += 360;
            const t = Math.min(1, Math.abs(facing) / 180); // 0 front .. 1 back

            const scale = (1 - 0.2 * t).toFixed(3);
            // Backface-hidden already hides the rear half; this keeps the
            // transition smooth and stops near-edge cards stealing pointer events.
            const pointer = Math.abs(facing) < this.step / 2 ? 'auto' : 'none';

            // An object binding merges with the inline `--card-angle` the Blade
            // baked in; a string binding would replace the whole style attribute
            // and lose it — every card would then sit at angle 0.
            return {
                opacity: (1 - 0.35 * t).toFixed(3),
                transform: 'rotateY(var(--card-angle)) translateZ(var(--cyl-radius)) scale(' + scale + ')',
                pointerEvents: pointer,
            };
        },

        /** Push the current `angle` to the ring element via the CSS var. */
        _apply() {
            const ring = this.$refs.ring;
            if (ring) ring.style.setProperty('--ring-angle', this.angle + 'deg');
        },

        /** Settle to the nearest card without unwinding multiple full turns. */
        _settle() {
            this.angle = this._restAngle();
            this._apply();
        },

        goTo(i) {
            if (this.count === 0) return;
            this.active = ((i % this.count) + this.count) % this.count;
            this._settle();
        },

        next() {
            if (this.count === 0) return;
            this.active = (this.active + 1) % this.count;
            // Continue turning in the same direction rather than snapping back.
            this.angle -= this.step;
            this._apply();
        },

        prev() {
            if (this.count === 0) return;
            this.active = (this.active - 1 + this.count) % this.count;
            this.angle += this.step;
            this._apply();
        },

        /** 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.startAngle = this.angle;

            // Disable the smooth transition while the finger drives the ring.
            const ring = this.$refs.ring;
            if (ring) ring.style.transitionDuration = '0s';

            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;
            const rtl = this._isRtl();
            // Drag right spins the ring so cards flow toward the viewer naturally;
            // mirror under RTL. ~0.3deg per px feels weighty without overshooting.
            const dir = rtl ? -1 : 1;
            const target = this.startAngle + dir * dx * 0.3;

            // Coalesce updates into one rAF per frame to avoid layout thrash.
            if (this._raf === null) {
                this._raf = requestAnimationFrame(() => {
                    this._raf = null;
                    this.angle = target;
                    this._apply();
                });
            }
        },

        _onPointerUp() {
            this.dragging = false;
            // Snap to whichever card is now closest to the front.
            const stepsTurned = this.step ? Math.round(-this.angle / this.step) : 0;
            this.active = ((stepsTurned % this.count) + this.count) % this.count;

            const ring = this.$refs.ring;
            if (ring) ring.style.transitionDuration = '';
            this._settle();
            this._teardownDrag();
        },

        _teardownDrag() {
            if (this._raf !== null) {
                cancelAnimationFrame(this._raf);
                this._raf = null;
            }
            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