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.
↓Scroll to step through it
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.
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.
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.
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.
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
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;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.
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
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;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.
- Pointer
- pan live — active ← offset.x / spacing
- Active slide
- foreground plate at offset 0
- Neighbours
- muted plates · flick |vx| > 600 nudges +1
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));}};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.
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.
Nightfall
Aurora Grey
Golden Hour
Marlowe
Deep Field
Vesper
Slow Tide
Costa
Paper Moons
Halden
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.
const rotateY = reduce ? 0 : -Math.max(-1, Math.min(1, offset)) * 52;
const z = reduce ? 0 : -Math.min(abs, 3) * 130;Motion Score
scaleScale spring / pressrotateRotationopacityFade / cross-fade