Anatomy of Hero Parallax
How one scroll progress value drives a tilting 3D plane and three rows drifting in alternating directions, all through spring-smoothed transforms.
Hero Parallax reads exactly one number off the page — scrollYProgress —
and feeds it into five useTransform mappings. No scroll listeners per
row, no manual rAF loop. Here's how one number becomes a drifting 3D grid.
Two motion values, three rows
Rows don't each get their own scroll math. There are only two: translateX
(scroll [0,1] → [0, 1000]) and translateXReverse ([0,1] →
[0, -1000]). Rows 1 and 3 read the first and lay out flex-row-reverse;
row 2 reads the second and lays out plain flex-row — that swap of layout
direction against the same-signed motion is the entire "alternating
drift" illusion.
- translateX
- rows 1 & 3 — scrollYProgress [0,1] → [0, 1000]
- translateXReverse
- row 2 — scrollYProgress [0,1] → [0, -1000]
const translateX = useSpring(useTransform(scrollYProgress, [0, 1], [0, 1000]), SPRING);
const translateXReverse = useSpring(useTransform(scrollYProgress, [0, 1], [0, -1000]), SPRING);<motion.div className="flex flex-row-reverse ..."> {/* row 1 & 3 */}
{firstRow.map((p) => <ProductCard translate={translateX} ... />)}
</motion.div>
<motion.div className="flex flex-row ..."> {/* row 2 */}
{secondRow.map((p) => <ProductCard translate={translateXReverse} ... />)}
</motion.div>The plane settles fast; the rows keep drifting
The whole plane's rotateX, rotateZ, translateY, and opacity all map
from scrollYProgress over just [0, 0.2] — the tilt is gone and the
plane is fully visible after the first fifth of the scroll. The row
translateX values, by contrast, map over the full [0, 1] — they keep
spreading the cards apart for the entire 300vh. Two different scroll
windows on the same signal, composited together.
useTransform(scrollYProgress, [0, 0.2], [...]) · useSpring(320, 32, 0.9)
- Plane
- rotateX 15→0 · rotateZ 14→0 · translateY, opacity .2→1 — first 20% of scroll
- Rows
- translateX / translateXReverse spread across the full scroll
const rotateX = useSpring(useTransform(scrollYProgress, [0, 0.2], [15, 0]), SPRING);
const rotateZ = useSpring(useTransform(scrollYProgress, [0, 0.2], [20, 0]), SPRING);
const translateY = useSpring(useTransform(scrollYProgress, [0, 0.2], [-700, 200]), SPRING);
const opacity = useSpring(useTransform(scrollYProgress, [0, 0.2], [0.2, 1]), SPRING);Every one of those runs through the same useSpring({ stiffness: 320, damping: 32, mass: 0.9 }) before it reaches the DOM, so a fast scroll or a
scrollbar drag never snaps the plane — it always eases toward wherever
scrollYProgress currently points.
Why transform, not scroll offsets
rotateX/rotateZ/translateY/translateX/opacity are the only
properties in motion. The container's [perspective:1000px] [transform-style:preserve-3d] sets up the 3D stage once; every row and the
plane itself then just transform inside it. Nothing re-measures layout on
scroll — the cost of a 300vh page is one useScroll subscription, not N
per-row listeners.
className="relative flex h-[300vh] flex-col ... [perspective:1000px] [transform-style:preserve-3d]"Accessibility and reduced motion
Each ProductCard is a real <a> when href is set, so the grid is
keyboard-reachable without any custom focus handling. Under
prefers-reduced-motion, the scroll-driven transforms are skipped
entirely — HeroParallax renders a calm static grid of the first nine
products instead of mounting the 300vh scroll stage at all.
if (reduce) {
return (
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
{products.slice(0, 9).map((product) => (
<img key={product.title} src={product.thumbnail} alt={product.title} />
))}
</div>
);
}The result
Every piece, assembled — scroll inside the panel below to watch the plane un-tilt and the rows drift apart. It starts already mid-reveal so the cards are visible right away.
Scroll to drift
Rows spread apart as the plane un-tilts into view.
One scroll progress value, five spring-smoothed mappings, and a layout swap that turns identical math into opposite drift — that's the whole hero.