MynduiMyndui
0

Anatomy of the Scroll Timeline

Why the rail grows with scaleY instead of height, how a spring keeps the fill from feeling jittery, and how a sticky node and a fade-up entry share one flex row.

01/04One rail, a node and a card per entry
Rail
one line, height = measured track height
Node
sticky dot, stays put while its entry is in view
Card
content, fades + slides up once on enter
tsx
<div ref={trackRef} className="relative mx-auto max-w-4xl pb-20">{data.map((item, index) => (  <div key={`${item.title}-${index}`} className="flex justify-start pt-10 md:gap-10 md:pt-24">    <div className="sticky top-24 z-base flex max-w-xs flex-col items-center self-start md:w-44 md:flex-row md:items-start">      {/* node */}    </div>    <motion.div whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }}>      {item.content}    </motion.div>  </div>))}</div>
01

One rail, a node and a card per entry

A scrollytelling rail that visibly "draws" as you read only works if the fill can grow every scroll frame without ever asking the layout engine to recompute anything. ScrollTimeline gets there with one measured number, one spring, and a transform that never touches height.

Each entry is a single flex row: a sticky node stays pinned to the rail while its section is in view, and a content card fades up once, the first time it enters. The rail itself is one absolutely-positioned line sized to the track's measured height — not one segment stitched per entry.

The line's total height can't be hard-coded — it depends on how much content each entry renders, which varies with the data and the viewport width. A ResizeObserver on the track keeps a height state in sync with whatever that turns out to be:

tsx
 
React.useLayoutEffect(() => {
  const el = trackRef.current;
  if (!el) return;
  const measure = () => setHeight(el.getBoundingClientRect().height);
  measure();
  const ro = new ResizeObserver(measure);
  ro.observe(el);
  return () => ro.disconnect();
}, []);

useLayoutEffect, not useEffect — the height is read before the browser paints, so the rail's container is sized correctly on the very first frame instead of flashing at zero height and snapping.

02/04scaleY, not height

scaleY: progress · transform-origin: top

Base rail
fixed line, always full height
Progress fill
scaleY overlay, grows with scroll
tsx
<div style={{ height }} className="absolute top-0 left-7 w-px overflow-hidden bg-border"><motion.div  style={{ scaleY: reduce ? 1 : progress, opacity: reduce ? 1 : lineOpacity }}  className="absolute inset-0 w-px origin-top rounded-full bg-[linear-gradient(to_top,var(--primary),transparent)]"/></div>
02

scaleY, not height

The fill is a fixed-height overlay clipped by overflow: hidden on its height-sized parent, transformed with scaleY from transform-origin: top. Scroll progress drives the scale, never the box's actual height.

scaleY is a transform, so growing the line every scroll frame is a composited operation — the browser stretches an already-painted layer. If this animated height instead, every single scroll event would force a layout recalculation on the rail's ancestors, which is exactly the kind of per-frame layout thrash useScroll-driven UI can't afford.

03/04Spring-smoothed fill
raw
spring

useSpring(progress, { stiffness: 320, damping: 32, mass: 0.9 })

scrollYProgress
matches the scroll position exactly, every event
Spring-smoothed
lags slightly, settles instead of snapping
tsx
const { scrollYProgress } = useScroll({target: trackRef,offset: ["start 10%", "end 60%"],});const progress = useSpring(scrollYProgress, {stiffness: 320,damping: 32,mass: 0.9,});
03

Spring-smoothed fill

useScroll reports scrollYProgress as a raw number that updates on every native scroll event — precise, but visually jittery, especially over a trackpad or an inertial mobile scroll. Feeding it straight into scaleY would make the rail twitch. ScrollTimeline re-emits it through a spring first.

stiffness: 320 keeps the spring tight enough that it doesn't visibly lag behind fast scrolling; damping: 32 is high enough relative to that stiffness that it settles without a visible bounce. The result tracks scroll closely but smooths out the frame-to-frame jitter raw scroll events carry.

The ["start 10%", "end 60%"] offset means the rail starts filling once the track's top has scrolled 10% into the viewport, and finishes once its bottom has scrolled up to the 60% line — not when the track enters or leaves entirely, which keeps both ends of the fill comfortably on screen.

Content fades in with Framer's whileInView, gated by once: true so it never replays on scroll back up:

tsx
 
<motion.div
  initial={reduce ? false : { opacity: 0, y: 12 }}
  whileInView={{ opacity: 1, y: 0 }}
  viewport={{ once: true, margin: "-10% 0px" }}
  transition={reduce ? { duration: 0 } : { duration: 0.4, ease: [0.22, 1, 0.36, 1] }}
>

The node beside it is plain sticky top-24 — no motion value at all. It doesn't need one: position: sticky already gives it the "pinned while its section passes" behavior for free, so the only animated things on the page are the rail's fill and each card's one-time entrance.

04/04Result

2021

A single component and a big idea.

2025

Every surface, polished — you're looking at the rail right now.

04

The result

One measured number driving a scaleY transform, smoothed through a spring, next to sticky nodes that need no JavaScript at all. Scroll the page through it.

Reduced motion

Under prefers-reduced-motion, the rail renders fully drawn (scaleY: 1) and every entry starts already visible (initial={false}) — the timeline is immediately readable top to bottom, with no scroll-driven reveal to wait out.

tsx
 
initial={reduce ? false : { opacity: 0, y: 12 }}
style={{ scaleY: reduce ? 1 : progress, opacity: reduce ? 1 : lineOpacity }}

Motion Score

Scroll TimelineSSCompositor-only
SscaleScale spring / press
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 →