← All writing
#Astro#GSAP#Animation

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

  1. Export frames from the source clip (I use a small ffmpeg script).
  2. Preload a few critical frames (first / middle / last) to drive a loading indicator, then load the rest in the background.
  3. 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),
  },
});
  1. 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.