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.
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
<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
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:
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.
-
resources/views/components/ui/carousel-cylinder.blade.php -
resources/js/ui/carousel-cylinder.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: 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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-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="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.
@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>
/**
* 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