Building a scroll-driven cinematic hero with Astro & GSAP
How I turned a short video into a buttery 218-frame canvas animation that scrubs with the scroll wheel - and kept it from tanking performance.
The hero on this site isn’t a video element - it’s a sequence of 218 still
frames painted onto a <canvas>, advanced by your scroll position. Here’s the
approach and the trade-offs I made.
Why frames instead of <video>
Scrubbing a <video> with the scroll wheel is unreliable across browsers -
seeking is janky and frame-accurate scrubbing isn’t guaranteed. Drawing
pre-exported frames onto a canvas gives you precise, deterministic control: at
scroll progress p, draw frame round(p × frameCount).
The cost is asset weight, so the frames are exported as compressed .webp and
the resolution is capped to what the hero actually needs.
The mechanics
- Export frames from the source clip (I use a small
ffmpegscript). - Preload a few critical frames (first / middle / last) to drive a loading indicator, then load the rest in the background.
- Drive the frame index with GSAP ScrollTrigger using
scrub, so the animation is tied to scroll position rather than time:
gsap.to(frameProxy, {
current: frameCount - 1,
ease: "none",
scrollTrigger: {
trigger: section,
start: "top top",
end: "+=350%",
scrub: 1,
onUpdate: () => renderFrame(frameProxy.current),
},
});
- Render by drawing the current frame’s image to the canvas 2D context.
The performance gotchas
- Don’t ship every frame set you tested. I A/B’d three quality levels and
only one belongs in
public/. - Decode ahead of where the user is, not all at once on mount - eager-loading the full set hurts time-to-interactive on mobile.
- Respect
prefers-reduced-motion- give people a static frame instead of hijacking their scroll.
Takeaway
Canvas frame sequences are the most reliable way to get film-like, scroll-accurate motion on the web. Budget for the bytes and lazy-load aggressively, and it stays smooth.