MynduiMyndui
0

Anatomy of Cover Flow

How a 3D album-cover carousel derives every slide's rotation, depth, and scale from one signed distance, and turns a drag into a spring-settled index.

01/03One signed distance, five slides

rotateY = -clamp(offset, -1, 1) × 52° · z = -min(|offset|, 3) × depth

Center slide
offset 0 — flat, full scale, faces the camera
Neighbours
offset ±1, ±2 — rotateY, translateZ, and scale by distance
tsx
const near = Math.min(abs, 1);const far = Math.max(abs - 1, 0);const x = sign * (near * spacing + far * spacing * 0.55);const rotateY = -Math.max(-1, Math.min(1, offset)) * 52;const z = -Math.min(abs, 3) * 130;const scale = 1 - Math.min(abs, 3) * 0.08;
01

One signed distance, five slides

Cover Flow doesn't animate five slides — it animates one number, active, and lets every slide read its own distance from it. Here's how that single signal becomes a 3D fan you can throw.

Each slide's offset is just index - active. Everything else — position, rotation, depth, scale, fade — is a pure function of that one signed number. The center slide (offset === 0) sits flat and full-scale; neighbours peel away in perspective the farther their offset runs.

Position telescopes instead of scrolling off-screen: the first unit of distance moves a full pitch, everything past that compresses to 55% of a pitch, so far slides keep queuing toward the edge instead of vanishing.

02/03Dragging rewrites the index, not the slides
Pointer
pan live — active ← offset.x / spacing
Active slide
foreground plate at offset 0
Neighbours
muted plates · flick |vx| > 600 nudges +1
tsx
const handlePan = (_e, info: PanInfo) => {goTo(Math.round(dragFrom.current - info.offset.x / spacing));};const handlePanEnd = (_e, info: PanInfo) => {if (Math.abs(info.velocity.x) > 600) {  goTo(active - Math.sign(info.velocity.x));}};
02

Dragging rewrites the index, not the slides

There's no per-slide drag handler. A single motion.div wraps the whole stage and reads one pan gesture; every frame it rewrites active from the drag offset, and the slides above just re-render off their new offset. Release fast enough — |velocity.x| > 600 — and the index nudges one extra slide in the throw direction, so a flick keeps going past where your finger stopped.

Every slide animates under the same spring — stiffness: 320, damping: 32, mass: 0.9 — so a drag, a click, or an arrow key all resolve with the same settle. There's no per-slide timing to keep in sync; changing active changes the target offset for all five slides at once, and Motion interpolates each one from wherever it currently sits.

tsx
 
const MORPH_SPRING = { type: "spring", stiffness: 320, damping: 32, mass: 0.9 };
// …
animate={{ x, rotateY, z, scale, opacity }}
transition={MORPH_SPRING}

x, rotateY, z, scale, and opacity are the only properties that ever change. All five compose onto one GPU layer per slide — the browser never re-measures or re-paints the stage while it drags or settles, no matter how many slides are stacked in depth.

03/03Result
Nightfall

Nightfall

Aurora Grey

Golden Hour

Golden Hour

Marlowe

Deep Field

Deep Field

Vesper

Slow Tide

Slow Tide

Costa

Paper Moons

Paper Moons

Halden

3 / 5
03

The result

Every piece, assembled — drag it, click a side slide, or tab in and use the arrow keys.

One signed distance, one spring, and a drag gesture that never touches a slide directly — that's the whole carousel.

Accessibility

The stage carries role="group" and aria-roledescription="carousel", and / step active the same way a drag does. When prefers-reduced-motion is set, rotateY and z are zeroed for every slide — the 3D fan flattens into a plain cross-fade, same index logic, no rotation.

tsx
 
const rotateY = reduce ? 0 : -Math.max(-1, Math.min(1, offset)) * 52;
const z = reduce ? 0 : -Math.min(abs, 3) * 130;

Motion Score

Cover FlowSSCompositor-only
SscaleScale spring / press
SrotateRotation
SopacityFade / cross-fade
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →