Wrapping Drum Carousel
Cards sit stacked at the centre of the stage and are pushed outward by their signed distance from a fractional carousel position. That distance is wrapped into a ring, so the sequence has no first or last card and the position variable can drift forever. The drum reads as three-dimensional but every transform is flat: `rotationY` and `z` are pinned to zero on purpose.
Wrapping Drum Carousel
Live preview
The real component, running in this page.
Install
Copies the source into your project. Nothing is imported from us at runtime.
npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/wrapping-drum-carousel.jsonAlso installs use-drag, use-reduced-motion, use-resize-observer, wrap from this registry.
Prompt
The whole technique, written for an agent.
Build it with your agent
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
Source
5 files, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | One card per child. Each is wrapped in a positioned cell the component controls. |
| spacingRatio | number | = 0.46 | Card spacing as a fraction of the stage width. |
| maxSpacing | number | = 560 | Ceiling on spacing in pixels, so wide screens do not fling the neighbours off-stage. |
| scaleFalloff | number | = 0.2 | How much a card shrinks by one slot out from centre. |
| className | string | — | Merged onto the stage element. |
| cardClassName | string | — | Merged onto every positioned cell. This is where you size the cards. |
| onIndexChange | (index: number) => void | — | Fires on every snap with the wrapped index, always in 0..count-1. |
How it works
The technique in order, not the code in order.
gsap.utils.wrap(-count / 2, count / 2)turnsi - posinto a signed ring offset in(-count/2, count/2]. For six cards atpos = 0, card 5 resolves to-1and renders one slot to the *left* of centre instead of five slots right — this single call is what makes a finite array behave like an endless one.Horizontal placement is
x = offset * stepwherestep = min(spacingRatio * stage.clientWidth, maxSpacing). Spacing is measured off the stage element, notwindow.innerWidth, so a preview in a grid cell lays out correctly when the grid reflows rather than only when the viewport does.Depth is faked by two curves with deliberately different clamps:
scale = 1 - scaleFalloff * min(distance, 1)bottoms out one slot from centre, whileautoAlpha = clamp(0, 1, 1.15 - 0.75 * distance)keeps falling and reaches zero at distance 1.533. Cards therefore fade out *before* they shrink far enough to look wrong, which reads as recession into fog rather than into a vanishing point.rotationY: 0andz: 0are written on everygsap.set. They are not decoration — they stop an ancestorperspectiveortransform-style: preserve-3dfrom turning a deliberately flat drum into a half-broken 3D one that skews at the edges.zIndex = 100 - round(10 * distance)is stepped, not continuous, so cards at similar distances do not flicker past each other asposcrosses an integer.Drag comes from
useDrag, which handles pointer capture, an 8 px dead zone and the capture-phase click swallow.onDragdividesdeltaXby the current spacing so the drum tracks the finger 1:1, andonDragEndsnaps toMath.round(pos)with a 0.7 spower3.outtween whoseonUpdatere-runs the same layout function.The wheel handler accumulates
deltaXand fires one step per 90 units, capped at 3 steps per event, then discards the remainder — an inertial trackpad flick otherwise keeps queuing steps after the fingers lift.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| spacingRatio | = 0.46 | Card spacing as a fraction of stage width. At 0.46 the neighbours are half-visible at the edges, which is what tells the user there is more to come. Below ~0.3 the cards overlap into a stack; above ~0.6 the neighbours leave the stage and the drum looks like a single card. |
| maxSpacing | = 560 | Upper bound in px. Without it a 3440 px monitor computes 1582 px of spacing and the neighbours are simply gone; the ratio has to stop scaling at roughly the width of one comfortable reading column. |
| scaleFalloff | = 0.2 | Neighbours render at 0.8. Less than ~0.12 and the depth cue disappears; more than ~0.3 and the off-centre cards look like a different component rather than the same one further away. |
| ALPHA_INTERCEPT / ALPHA_SLOPE | = 1.15 / 0.75 | Alpha is 1.15 - 0.75 * distance. The intercept above 1 keeps the first ~0.2 of travel at full opacity so the centre card does not start dimming the instant you touch it; the slope puts the zero crossing at distance 1.533, comfortably past where scale clamps. |
| SNAP_DURATION | = 0.7 | Seconds for the release snap. Under ~0.45 the settle is abrupt enough that the drum reads as stepped; over ~0.9 a second drag arrives before the first has landed. |
| WHEEL_STEP | = 90 | Accumulated wheel units per index step. Matches roughly one detent on a mouse wheel and one deliberate trackpad swipe; at 40 a single flick advances four cards. |
| MAX_WHEEL_STEPS | = 3 | Ceiling on how many steps one wheel event may fire. A high-resolution trackpad can deliver a deltaX of 400+ in one event, which without the cap jumps five cards from a gesture the user read as one. |
| DRAG_THRESHOLD | = 8 | Pixels before a press counts as a drag. Below ~4 a click on a touchpad registers as a drag and the click swallow eats the tap; above ~12 short deliberate flicks are ignored. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
Drag still tracks the finger 1:1 — direct manipulation is not motion the user did not ask for. What changes is the release: instead of a 0.7 s power3.out tween, pos is assigned the target index and the layout runs once, so the drum arrives in a single frame. Wheel steps land instantly for the same reason.
Removes the wheel listener, kills the tween on the internal position object, kills any tween on the card elements and clears every property GSAP wrote with clearProps: "all". useDrag releases pointer capture and removes its own five listeners. Ordering matters: tweens are killed before props are cleared, or an in-flight tween writes the transform straight back.
Drag left past the last card and the first one arrives from the right without a gap or a jump. Release mid-way and it settles onto the nearest card. Neighbours are visibly smaller and dimmer, and inspecting a card shows a matrix() transform with no 3D component. Scroll the page vertically with the cursor over the carousel and the page scrolls normally; swipe horizontally and the drum steps one card.
Pitfalls
- Reaching for
rotationYandtranslateZto get the drum look. This technique is flat on purpose — real 3D needs a shared or per-element perspective decision, correcttransform-styleon every ancestor, and it renders text on a rotated plane, which is where the blurring comes from. WriterotationY: 0, z: 0explicitly so an ancestor cannot opt you into 3D by accident. - Accumulating position instead of recomputing it. If the layout adds a delta to each card's current transform, interrupting the snap tween mid-flight leaves the cards permanently offset from
pos. Layout must be a pure function ofposand nothing else. - Using
opacityinstead ofautoAlpha. Atopacity: 0the wrapped-around cards are still hit-testable, so a click intended for the centre card lands on an invisible one stacked on top of it.autoAlphaalso setsvisibility: hidden, which removes them from hit-testing. - Forgetting to kill the snap tween on
onDragStart. The tween keeps writingposunderneath the drag and the cards visibly stutter against the finger. - Calling
preventDefaulton every wheel event. A vertical trackpad scroll over the carousel then does nothing and the page appears frozen. Only the horizontal component is claimed here —Math.abs(deltaX) > Math.abs(deltaY)gates the whole handler. - Registering the wheel listener as passive.
preventDefaultis silently ignored on a passive listener and Chrome logs an "Unable to preventDefault inside passive event listener" warning on every event. - Computing spacing from
window.innerWidth. The carousel is then correct on load and wrong the moment it sits inside anything narrower than the viewport — a sidebar, a modal, a preview grid.
Notes
- This is one of three deliberately different carousel techniques in the registry, and they must not be collapsed into one. This one is **fake 3D driven by a JS position variable**:
gsap.utils.wrapmaps a continuous, unbounded index onto the item count, and depth is scale plusautoAlphaonly.perspective-coverflow-scrolleris **true 3D driven by a native scroll container** — realrotateY/translateZ, no JS loop.percent-slide-carouselis **flat percentage translation with a CSS transition** and no wrapping at all. - Choose this one when the sequence must be endless in both directions and the item count is small enough that every item can stay mounted. It cannot virtualise: every card is laid out on every frame of a drag.
touch-action: pan-yon the stage tells the browser that horizontal gestures belong to the carousel and vertical ones to the page. Without it, dragging on a phone either scrolls the page or forces you topreventDefaulteverytouchmove, which fights the browser instead of cooperating with it.