Pattern 03 — position: sticky + progress math
The pinned scene
Scrollytelling's signature move: the page appears to stop while the story keeps
going. Newsrooms built a decade of visual journalism on it — datelines that hold
while maps march, charts that assemble line by line as the copy explains them.
The trick is a contract between two pieces: position: sticky pins a
full-viewport stage inside a much taller section, and a few lines of JavaScript
convert "how far through that tall section are we?" into a number between 0 and 1.
Everything downstream is just interpretation of that number. Three steps? Divide by a third. A progress bar? Scale it. The scene below runs on exactly this:
const rect = scene.getBoundingClientRect();
// runway = section height minus the 100vh that stays pinned
const runway = rect.height - window.innerHeight;
const progress = Math.min(1, Math.max(0, -rect.top / runway));
const step = Math.min(2, Math.floor(progress * 3)); // 0, 1, 2
Scroll on — the next section pins itself for three beats.
Arrive
A component begins as a signal: something exists here. One dot, one ring of attention. Motion's first job is simply to say "look — but gently".
Take shape
Structure precedes content. The skeleton state promises layout before the data lands, so nothing jumps when it does — continuity is the whole game.
Settle
Content arrives, motion retires. A finished interface should look like it was always going to end up exactly here — the animation becomes memory.
Why 300vh, and other tuning notes
The runway height is the scene's pacing control. 100vh of extra height per step is a comfortable reading tempo on desktop and mobile alike; drop to 60–70vh per step for punchier product pages, stretch past 120vh only when a step contains something genuinely worth lingering on. Resist the 1000vh epic — readers can feel a hijacked scrollbar, and trust, once scrolled away, does not come back.
Note what the JavaScript here does not do: it never pins, unpins, or
measures on every pixel in a layout-triggering way. Sticky positioning is native
and free; the script reads one rectangle per frame (batched through
requestAnimationFrame, passive listener) and writes one custom property plus a
step index. When steps change, CSS transitions do the visible work — and under
prefers-reduced-motion those transitions collapse to plain
crossfades while the pinning, which is layout rather than motion, stays.
One accessibility habit worth keeping: the step captions are real text in the document, in reading order, visible at reduced opacity rather than hidden. A screen reader — or a browser with JavaScript off — encounters a sensible, complete article: three numbered paragraphs and a figure. The scene is presentation, not payload.