Perspective Coverflow Scroller
A horizontal `overflow-x-auto` container with `scroll-snap-type: x proximity`. Each panel's distance from the centre of the viewport is normalised to roughly ±1 and mapped onto a per-element `perspective() translateZ() rotateY() scale()`, so panels turn their inside edge toward you as they leave the middle. The scroll position itself is never animated by JavaScript — momentum, rubber-banding and keyboard paging are the browser's.
Perspective Coverflow Scroller
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/perspective-coverflow-scroller.jsonNo runtime dependencies.
Also installs use-reduced-motion, use-resize-observer, damp 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
4 files, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | One panel per child. Each is wrapped in a snap-aligned cell. |
| perspective | number | = 1300 | Per-element perspective distance in px. |
| rotation | number | = 40 | Degrees of rotateY at the clamp. |
| depth | number | = 130 | Pixels of translateZ at the clamp. Applied negatively, so panels recede. |
| scaleFalloff | number | = 0.12 | How much a panel shrinks one half-viewport out. |
| opacityFalloff | number | = 0.35 | How much a panel fades one half-viewport out. |
| maxOffset | number | = 1.2 | Clamp on the normalised offset, so distant panels stop turning further. |
| initialIndex | number | — | Panel centred on mount. Defaults to the middle of the set. |
| className | string | — | Merged onto the scroll container. |
| panelClassName | string | — | Merged onto each snap cell. Size the panels here. |
| aria-label | string | = 'Gallery' | Label for the scrollable region, which is focusable for keyboard scrolling. |
How it works
The technique in order, not the code in order.
A measure pass caches each panel's layout centre as
panel.offsetLeft + panel.offsetWidth / 2and sets the track's left and right padding to(scroller.clientWidth - endPanel.offsetWidth) / 2, which is exactly enough runway for the first and last panel to reach the middle of the viewport.On every coalesced scroll frame, the normalised offset is
r = clamp((centre - scrollLeft - half) / half, -1.2, 1.2)wherehalf = scroller.clientWidth / 2.r = 0is dead centre,r = ±1is one half-viewport out.That single scalar drives four things:
rotateY = -rotation * r(negative, so a panel on the right turns its left edge toward you — the flip-book read),translateZ = -depth * |r|(always receding),scale = 1 - scaleFalloff * min(|r|, 1), andopacity = 1 - opacityFalloff * min(|r|, 1.2).perspective(1300px)is written inside each panel's own transform rather than as aperspectiveproperty on the track. A shared parent perspective gives every panel the same vanishing point, so panels near the edges skew as well as rotate; per-element perspective turns each panel about its own centre.The two clamps differ on purpose —
min(|r|, 1)for scale andmin(|r|, 1.2)for opacity — so distant panels keep their proportions while continuing to fade out.A
ResizeObserveron the track re-runs the measure pass whenever content size changes (a font swap, a child growing), which is the deterministic replacement for the usual pile ofsetTimeout(recompute, 300)calls.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| perspective | = 1300 | Per-element perspective distance in px. Under ~700 the foreshortening is strong enough that a 40° turn hides most of the panel; over ~2000 the rotation flattens out and reads as a skew rather than a turn. |
| rotation | = 40 | Degrees of rotateY at the clamp. 40° is the point where the panel is visibly turned but still legible; past ~55° it is close enough to edge-on that text disappears and antialiasing on the near edge gets ugly. |
| depth | = 130 | Pixels of translateZ, always negative so off-centre panels recede. Combined with the rotation this is what makes the row read as the inside of a cylinder rather than a fanned deck. At 0 you get a pure hinge; past ~250 the far panels shrink faster than scaleFalloff intends and the two cues disagree. |
| scaleFalloff | = 0.12 | A panel at one half-width out renders at 0.88. Kept small because translateZ is already shrinking it perspectivally — stacking a large explicit scale on top double-counts the distance. |
| opacityFalloff | = 0.35 | The edge panels land near 0.58, not 0. They must stay readable, because on a proximity-snap track they are the affordance that tells you there is more to scroll to. |
| maxOffset | = 1.2 | Clamp on r. Without it, a long track keeps rotating panels past 90° and they flip to their back faces; 1.2 rather than 1.0 leaves a little travel past the half-width mark so the outermost visible panels are not all frozen at an identical angle. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
The scroller keeps working exactly as it did — it is a real scroll container, and scrolling is user-driven, not animation. What is removed is the 3D: transform and opacity are cleared on every panel, so the row renders as a flat, fully opaque filmstrip that still snaps by proximity. Nothing moves that the user did not move.
Removes the scroll listener, cancels any pending requestAnimationFrame, and disconnects the track ResizeObserver. The shared element observer unregisters the scroller. Inline transform/opacity written on panels dies with the nodes; no timer or tween outlives the component.
On mount a panel sits centred and the ones either side are visibly turned toward it. Drag or trackpad-scroll and the turn follows continuously with no lag and no snap-back; release near a panel and it snaps, release between two and it does not. Scroll to either end and the outermost panel reaches the exact centre of the container. Resize the container and the end padding recomputes so it still can.
Pitfalls
- Computing the offset from
getBoundingClientRect(). The rect reflects the transform this component just wrote, so each panel'sscale()feeds back into the next frame's offset and the row slowly drifts to a wrong steady state.offsetLeft/offsetWidthare layout values — untouched by transforms and byscrollLeft. - Putting
perspectiveon the track instead of in each panel's transform. Every panel then shares one vanishing point, so the ones near the edges skew into parallelograms instead of turning. - Handling
scrollwithout coalescing into a frame. A trackpad fires scroll events considerably faster than the compositor paints, and every run writes N style strings for no visible benefit. - Padding the track with a viewport unit like
24vw. It is correct for exactly one panel width and one container width; inside a card, a modal or a split layout the end panels can never reach the centre and are permanently unselectable. - Setting
scroll-snap-type: x mandatory. Release between two panels and the browser yanks you to one of them, which fights every attempt to scan the row.proximitysnaps only when you were already close. - Starting at
scrollLeft = 0. The first panel is then hard against the left edge with nothing to its left, so the effect is invisible until the user scrolls. Centring on a panel on mount shows the technique in both directions immediately. - Re-centring on every resize.
scrollLeftis only assigned once, guarded by a ref — otherwise rotating a phone, or any resize during a scroll, teleports the user back to the middle mid-gesture.
Notes
- One of three deliberately different carousel techniques here, and the distinctions are the point. This one is **true 3D on a native scroll container**: actual
rotateYandtranslateZ, and no JavaScript animation drives the scroll at all — the handler only reads a position the browser already set.wrapping-drum-carouselis **fake 3D** driven by a tweened JS position withrotationY: 0, z: 0pinned, and wraps infinitely viagsap.utils.wrap.percent-slide-carouselis **flat, bounded, one CSS transition per index change**. - Choose this one when the set is long, scanning matters more than stepping, and you want native momentum and keyboard support for free. It cannot loop: a native scroll container has real ends.
- Because the panels rotate in 3D, text inside them is composited on a rotated plane and will look softer than flat text. That is inherent to the technique, not a bug to tune away; keep small type out of the panels or accept it.