MynduiMyndui
0

Anatomy of the Container Scroll

How a product-hero device frame un-tilts from 3D as you scroll, driven by one scroll-progress value feeding three independent springs.

01/03Header and frame, sharing one section
Header
translateY 0 → -100 as the section scrolls
Frame
rotateX 20° → 0°, scale 1.05 → 1, in its own 3D stage
tsx
<div ref={containerRef} className="relative flex h-[60rem] items-center justify-center md:h-[80rem]"><div className="relative w-full [perspective:1000px]">  <motion.div style={{ translateY }} className="mx-auto mb-10 max-w-3xl text-center">    {header}  </motion.div>  <motion.div style={{ rotateX, scale }} className="[transform-style:preserve-3d]">    {frame /* the border + aspect-[16/10] screen */}  </motion.div></div></div>
01

Header and frame, sharing one section

A hero that un-tilts as you scroll past it looks like it needs a scroll listener, some math, and a requestAnimationFrame loop. ContainerScroll is one useScroll call and three useTransforms riding it.

ContainerScroll renders two children into the same tall section: a header block above, and the device frame below it in its own [perspective:1000px] stage. Neither knows about the other — both just read transforms handed down from the same scroll progress:

The section is deliberately tall (60rem, 80rem on md:) — there has to be enough scroll distance for scrollYProgress to move from 0 to 1 at a readable pace, or the whole animation resolves in a couple of frames.

02/03One progress value, three springs

scrollYProgress

frame
rotateX 20°→0° · scale 1.05→1
header y
0 → -100
progress
scrollYProgress fill
tsx
const SPRING = { stiffness: 320, damping: 32, mass: 0.9 } as const;const { scrollYProgress } = useScroll({ target: containerRef });const rotateX    = useSpring(useTransform(scrollYProgress, [0, 1], [20, 0]), SPRING);const scale      = useSpring(useTransform(scrollYProgress, [0, 1], [1.05, 1]), SPRING);const translateY = useSpring(useTransform(scrollYProgress, [0, 1], [0, -100]), SPRING);
02

One progress value, three springs

useScroll({ target: containerRef }) returns scrollYProgress0 when the section's top hits the bottom of the viewport, 1 when its bottom hits the top. Three useTransforms remap that single number to three different ranges, and each gets its own useSpring for the smoothing, so all three settle with the same feel without being coupled to each other's code:

Scroll down and rotateX, scale, and translateY all move together because they're three views onto the same underlying scrollYProgress motion value — there's no explicit synchronization code because there's nothing to synchronize.

rotateX only reads as "tilting back in 3D" if the browser has a vanishing point to rotate toward — that's what [perspective:1000px] on the parent of the rotated element provides, and [transform-style:preserve-3d] on the frame itself keeps its own children (the border, the screen) flat against it instead of each getting their own independent 3D space. Get the perspective container wrong — put it too close, or nest it one level off — and the tilt looks like a lopsided scaleY instead of a plane rotating in depth.

03/03Result

Scroll to bring it to life

The frame un-tilts and settles as you scroll.

Dashboard
03

The result

One useScroll, three useTransforms, three springs for the smoothing — the entire "product hero" moment, tied to nothing but how far you've scrolled.

Reduced motion is a different tree, not a toggle

Unlike most Myndui components, ContainerScroll doesn't disable transforms under prefers-reduced-motion — it renders an entirely different, shorter tree: no containerRef-driven useScroll, no 3D stage, just the header and an upright frame stacked in normal flow.

tsx
 
if (reduce) {
  return (
    <div className="flex flex-col items-center px-4 py-16">
      {header ? <div className="mb-10 text-center">{header}</div> : null}
      {frame}
    </div>
  );
}

That's a deliberate trade: a shorter, non-scroll-driven section instead of a tall one with its motion quietly zeroed out. Readers who've asked for less motion also don't get asked to scroll through 60rem of empty tilt to reach whatever comes next.

Motion Score

Container ScrollSSCompositor-only
StranslatePosition / lift via translate
SrotateRotation
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 →