Five problems, one hero fold.
The hero video needed to feel directly tied to the scrollbar — like a native range input — not like a clip that merely starts and stops. Every challenge below traces back to that one constraint.
01 — Playback
Scroll-scrubbed video without stutter
Calling video.currentTime = on every scroll tick was ruled out — repeated seeks on compressed video are expensive and visibly stutter. The fix: a "play-rate chasing" driver on requestAnimationFrame. Each frame compares current time to a scroll-derived target; small gaps do nothing, larger gaps raise playbackRate up to 8× and let the browser's own decode pipeline catch up, falling back to a hard seek only when scrolling backwards.
requestAnimationFrame
playbackRate chase
Hard-seek fallback
8×
02 — Cross-Browser
Safari couldn't keep up
Safari handles playbackRate and blob-URL seeking very differently from Chromium — the chasing technique broke down there. Rather than force one code path everywhere, the driver branches per-browser: Safari gets direct currentTime scrubbing against a preload="auto" src, accepting readyState >= 1 instead of waiting for full buffering, while Chromium keeps the smoother play-rate chase against a preloaded Blob URL.
Per-browser branch
Direct seek (Safari)
Blob URL (Chromium)
2 paths
03 — Load Strategy
Zero-lag start
To avoid a first-scroll stutter while the browser fetches the video, the full file streams via fetch() with a ReadableStream reader, is assembled into a Blob, and swapped in as a blob: URL — with a visible load-percentage screen so the wait reads as intentional, not broken.
ReadableStream
Blob URL
Load-% screen
Blob
04 — Sync Architecture
One ScrollTrigger, two animations
An early version used separate ScrollTriggers for the video scrub and the sidebar word highlights, which caused desync — words highlighting simultaneously, or not at all, across browsers. The fix was architectural: a single pinned ScrollTrigger owns scroll progress, and both the video's target time and the active sidebar word derive from the same onUpdate callback, eliminating any chance of the two drifting apart.
Single pinned trigger
Shared onUpdate
1 trigger
05 — Responsive
Full mobile responsiveness
Every section was rebuilt to work from 360px up: the desktop left/right split scroll-text layout collapses to stacked centered text, GSAP animations drop their horizontal offset on mobile so nothing slides in from off-screen, the header collapses to logo + cart with a slide-down menu, and the product grid reflows 1 → 2 → 3 columns with clamp()-based fluid typography throughout.
Stacked layout
Fluid clamp() type
1→2→3 grid
360px