GodUIGodUI
81

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.

Anatomytwo motion values, three rows
translateX
rows 1 & 3 — scrollYProgress [0,1] → [0, 1000]
translateXReverse
row 2 — scrollYProgress [0,1] → [0, -1000]
tsx
 
const translateX = useSpring(useTransform(scrollYProgress, [0, 1], [0, 1000]), SPRING);
const translateXReverse = useSpring(useTransform(scrollYProgress, [0, 1], [0, -1000]), SPRING);
tsx
 
<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.

The motionscrub down, then back up

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
tsx
 
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.

tsx
 
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.

tsx
 
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.

Resultthe real component — scroll inside this panel

One scroll progress value, five spring-smoothed mappings, and a layout swap that turns identical math into opposite drift — that's the whole hero.

On this page

Built with GodUI

Beautifully crafted motion components for modern interfaces.

Star on GitHub