Build notes, September 2026
How I built a scroll-driven video hero that still scores 100
The hero on this site is a ten second film. Your scroll is its timeline: scroll down and the camera descends, scroll back and it climbs. It streams while it downloads, it never loads at all on phones, and the page still scores 100 for performance on Google's test. Here is how it works, and the four traps that cost me the most time.
The idea
A video that plays on scroll is not a video that autoplays. There is no playback at all. The page maps scroll position to video.currentTime, so the viewer is scrubbing, exactly like dragging the playhead in a video editor. The hero is seven screens tall and pinned, and five caption bands fade in and out across that distance.
1. The scroll engine never touches React state
Scroll drives the video and five caption bands about sixty times a second. Routing that through React state would mean a re-render per animation frame, which is exactly the cost this design cannot afford. So React owns the markup, and a hook owns the loop, writing to nodes through refs, only when a value actually changed.
// normalised to a 60fps reference, so the feel is frame rate independent:
// a plain per-frame constant converges twice as fast on a 120Hz screen
const k = 0.16;
shown.current += (target.current - shown.current) * (1 - Math.pow(1 - k, dt / 16.667));Two details matter here. The easing is time-based, not frame-based, so a 120Hz laptop and a 60Hz monitor feel the same. And the loop stops itself once the value converges, instead of running forever.
2. Seeks have to be gated
Writing currentTime while a seek is already in flight piles requests up, and that pile is the difference between smooth and choppy in Chrome. One flag, one pending target:
if (seekBusy.current) {
pendingTime.current = t; // coalesce: keep only the newest target
return;
}
seekBusy.current = true;
video.currentTime = t;
// on 'seeked': release, then run the one pending target, if any
// on 'error': release as well, or a failed seek deadlocks the gate foreverThe error handler is not optional. Without it, one failed seek leaves the gate closed and the hero freezes for good.
3. The film plays while it is still downloading
The first version fetched the whole file, then started. On a normal connection that meant the picture sat frozen for eight to fourteen seconds after the page had visibly finished loading. Anyone who scrolled immediately thought the site was broken.
The fix has two halves. First, the file is encoded as a fragmented MP4, with a keyframe and a fragment every eight frames:
ffmpeg -i master.mp4 -an -c:v libx264 -pix_fmt yuv420p -profile:v high -level 3.1 \
-crf 27 -preset slow -g 8 -keyint_min 8 -sc_threshold 0 \
-movflags frag_keyframe+empty_moov+default_base_moof hero.mp4Second, each chunk of the download is handed straight to the video through Media Source, and every seek is held to the frames that have actually arrived:
if (!fullyLoaded.current) {
const b = video.buffered;
const edge = b.length ? b.end(b.length - 1) - EDGE : -1;
behind.current = t > edge; // the scroll is ahead of the download
if (edge < 0) return; // nothing decodable yet
if (t > edge) t = edge; // show the furthest frame that has arrived
}When more data lands, anything marked behind is re-seeked, so the picture catches up on its own. Measured on the live site: the picture starts following the scroll 1.5 seconds after the page opens, and the whole file is in by 3.3 seconds. Before, nothing moved for twelve.
Browsers without Media Source get the whole file as a Blob, which is also why the video is fetched by the page rather than handed to the <video> element: plenty of hosts silently lack HTTP Range support, and without it every seek clamps to zero. Scrubbing then works locally and does nothing in production.
4. Phones never download the film at all
A scrubbed video is a desktop idea. On phones it is a 2MB download to fight with a touch scroll. Five conditions swap the whole thing for a composed still:
export const GATES = [
'(max-width: 720px)',
'(orientation: portrait) and (max-width: 1024px)',
'(orientation: portrait) and (pointer: coarse)',
'(orientation: landscape) and (pointer: coarse) and (max-height: 560px)',
'(prefers-reduced-motion: reduce)',
];These strings have to stay character for character identical to the media query list in the stylesheet, or one side loads what the other side hides. They are also checked live, not once at load: a tablet rotating, a window being maximised, or reduced motion switched off mid-session all re-evaluate the CSS, so the JavaScript has to re-evaluate with it.
The four traps that cost me the most
- A page fade that hid the Largest Contentful Paint. The page faded in from
opacity: 0. Chrome discards text first painted at zero opacity, and a compositor fade never repaints it, so mobile had no measurable LCP at all: a failed Core Web Vital, and a performance score of zero. Removing the fade fixed it. - Prerendering revealed the headline three times. The split-text animation renders a blurred copy and a sharp copy over the real line. Invisible in a client-rendered page; in prerendered HTML, search engines and link previews read "Hadi MoussawiHadi MoussawiHadi Moussawi". Now the animated copies carry no text: their letters are drawn with
content: attr(data-ch), which is not page text. - A strict Content-Security-Policy broke the prerendered page. Prerendered HTML arrives with about 120 inline
styleattributes: per-letter animation offsets, the FAQ's collapsed height, the form's hidden spam trap.style-src 'self'blocks those at parse time, and React does not reapply them on hydration. The fix is to allow inline style attributes whilestyle-src-elem 'self'still forbids inline<style>blocks. - A cached copy of the old video broke the new player. Files in
/assetsare cached for a week. When the re-encoded, fragmented file shipped under the same name, returning visitors kept the old one, which Media Source cannot play, so the hero failed outright for them. A new file name misses every stale cache. This is the one I would most want you to remember.
What it measures
- Lighthouse on the live site: 100 performance, 100 accessibility, 100 best practices, 100 SEO.
- Largest Contentful Paint 0.47s on desktop, 1.64s on mobile.
- The page is prerendered, so about 1,450 words are readable with JavaScript switched off.
- Worst-case text contrast over the moving film, measured pixel by pixel: 8.76:1.
Would I do it again?
For a portfolio, a venue, a product launch: yes. For a business that needs to answer a question and take a booking: no, and I would say so. The interesting part is not the effect. It is that a page can carry a ten-second film and still load faster than most text-only sites, if every heavy thing is made optional and nothing lies to the browser.