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' })

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