Zenith's homepage has a long pinned section. A sample report sits in the middle of the screen while you scroll through five stages: the request goes out, the score comes in, findings arrive, a fix prompt writes itself, and a re-scan marks the first finding fixed.
The first version tied everything to the scrollbar with CSS scroll-driven animations. The score counted up as you scrolled, bars grew as you scrolled, and the prompt revealed itself through a mask as you scrolled. It ran off the main thread and needed no JavaScript. It also looked broken most of the time.
The problem with scrubbing
Scrubbing makes every value a function of scroll position. People don't scroll in smooth, even passes: they flick, pause and stop halfway. So most of the time the card was halfway too, with a score of 37 that meant nothing, bars at a third of their length and half a sentence of the prompt. A screenshot at a random scroll position showed a card full of zeros, and so did the page for anyone who paused there.
The fix was to separate two jobs that scrubbing welds together. Deciding which stage the reader is at really is a function of scroll position. Animating the change between stages should have its own clock.
Scroll picks the stage
The pinned section is 400vh tall. A passive scroll listener, throttled to one measurement per animation frame, turns scroll progress into a stage number. React only re-renders when that number changes, so a full pass through the section costs four renders, not hundreds.
const THRESHOLDS = [0.08, 0.32, 0.56, 0.8];
const r = el.getBoundingClientRect();
const travel = Math.max(1, r.height - window.innerHeight);
const p = Math.min(1, Math.max(0, -r.top / travel));
setStage(THRESHOLDS.filter((t) => p >= t).length);Time drives the motion
The stage lands on the section as data-stage, and each part of the card says which stage it belongs to with data-step. Showing a part uses its own stagger. Hiding it, when you scroll back up, is quick and has no delay, so rewinding feels responsive rather than theatrical.
[data-live] [data-step] {
transition: opacity 320ms var(--ease) var(--d, 0ms),
transform 560ms var(--ease-out) var(--d, 0ms);
}
[data-live][data-stage="1"] :is([data-step="2"], [data-step="3"]) {
opacity: 0;
transform: translateY(10px);
transition-duration: 160ms;
transition-delay: 0ms;
}The numbers count up over 900 milliseconds with requestAnimationFrame, and only when they switch from off to on. The prompt types over a second and a half. Because they run on time rather than scroll, they always finish, and a reader who pauses always sees a complete stage.
Phones get the same story, played by itself
The section only pins on wide, tall screens without reduced motion. Everywhere else there's no long scroll to map, so once the card is 12% visible the stages play through on timers. It's the same component and the same CSS; only the thing that picks the stage changes.
(prefers-reduced-motion: no-preference) and (min-width: 1000px) and (min-height: 720px)The part that almost shipped broken
The homepage is prerendered: after the build, a headless browser loads each route and saves the DOM, and the client hydrates that HTML. The new component takes over after mount, so the prerender browser ran it too and saved the page frozen at stage zero: "Scanning", a score of 0 and an empty card. That's also what a visitor without JavaScript would have been left with, and it didn't match the first render React expected when hydrating.
The fix was one line in the prerender script. With reduced motion the component never takes over, so the snapshot is its first render: the finished report.
const page = await browser.newPage({ reducedMotion: "reduce" });The card should start hidden so it can pop out, but only where scripts will actually run. The scripting media feature handles that, with a failsafe in case the script never arrives.
@media (scripting: enabled) and (prefers-reduced-motion: no-preference) {
.story:not([data-live]) .panel {
opacity: 0;
animation: story-failsafe 1ms 3s forwards;
}
}What changed
The change removed 300 lines of scroll-scrubbed CSS and added 220 lines of plain transitions. The staged version also works in browsers without scroll-timeline support, and it never shows a half-finished card.
Glossary
- Scrubbing
- Tying an animation's progress directly to scroll position, so it moves forwards and backwards with the scrollbar.
- Hydration
- React attaching to HTML that was rendered ahead of time, instead of building the page from scratch in the browser.
- Prerendering
- Saving each page's HTML at build time so it arrives complete, before any JavaScript runs.