The Problem: Video Scrubbing Is Unreliable
Scroll-scrubbing a `` element is async, stutters on keyframe intervals, and fights mobile Safari on autoplay. Pratham Sharma's first portfolio had 12.8 MB of media, 210 kB First Load JS, and scroll jank on anything weaker than a desktop GPU.
Solution: Frame Sequences on Canvas
Each 45-second clip is extracted to 61 frames at 12 fps, 1280×720, encoded as WebP, and drawn onto a ``. GSAP's ScrollTrigger drives a progress value from 0 to 1; the component maps it to a frame index and draws it deterministically.
ffmpeg -i clip.mp4 -vf "fps=12,scale=1280:-2" /tmp/frames/%03d.jpg
for f in /tmp/frames/*.jpg; do
cwebp -q 68 -m 6 "$f" -o "public/media/seq/dig/$(basename "${f%.jpg}").webp"
done
Quality Sweep
Sharma encoded the same sequence at several cwebp quality levels and diffed visually. -q 68 -m 6 was the sweet spot: ~20% smaller than JPEG frames with no visible loss on moving, partially-masked footage. Total media: 12.8 MB → 9.8 MB.
DPR Cap
Canvas backing store is capped at devicePixelRatio 1.5. On a 3× phone screen, the difference on moving footage is imperceptible, and it's less than half the pixels to paint.
Lazy-Loading with Teeth
244 frames are not fetched up front. Each sequence lazy-loads through two gates:
const io = new IntersectionObserver(start, { rootMargin: '800px 0px' })
- An
IntersectionObserverwith 800px margin starts a sequence only when the user approaches. - A
requestIdleCallback(withsetTimeoutfallback) defers decoding off the critical path. - A
priorityprop marks the hero sequence: first beat loads immediately with default fetch priority; every other sequence getsimg.fetchPriority = 'low'.
Result: first paint needs one sequence, not four. Rest stream in while you read.
Code-Split Below the Fold
The page is one route, but not one bundle. Everything below the fold became a next/dynamic import — acts, marquee, projects rail, footer. Purely decorative client-only components got ssr: false.
First Load JS: 210 kB → 162 kB. Only nav, hero sequence, and custom cursor block.
Cache Immutable Things Immutably
Frame sequences never change (filename changes when they do). Served with:
Cache-Control: public, max-age=31536000, immutable
via headers() in next.config.ts. Second visit: zero media requests.
Stop Animating When Nothing Moves
The custom cursor ring used a permanent requestAnimationFrame loop, easing toward pointer at rest — 60 fps of work to move 0 pixels. Now the loop kills itself when the ring settles within 0.1px and restarts on next mousemove. Idle CPU when page is idle.
Key Takeaways
- Scroll-scrubbed video is a frame-sequence problem, not a video problem.
- Sweep encoder quality against your actual footage. For masked, moving frames, 68 is far below what you'd pick for stills.
- "Lazy" needs teeth: observer margin, idle callback, explicit fetch priority.
- Measure before and after: 12.8→9.8 MB and 210→162 kB produce a non-boring difference on a mid-range phone.
