karthik.dev
Back to blogFrontend Engineering

Scroll-Driven Animations with Canvas: How This Portfolio's Background Works

2026-06-28 · 8 min read

The animated background on this portfolio is a canvas element that plays 300 JPEG frames as you scroll. It looks like a video but behaves like a timeline scrubber. Here's the complete technical implementation.

Why not just use a video element?

Three reasons:

  1. <video> doesn't support scrubbing to arbitrary frames on scroll without significant jank on most browsers
  2. Canvas gives pixel-perfect control over frame selection and interpolation
  3. Decoupling frame selection from scroll position with lerp produces a much smoother feel than direct video currentTime manipulation

The preloading loop

All 300 frames are preloaded on component mount. The key detail is img.decoding = "async" — this tells the browser to decode JPEGs off the main thread, preventing paint jank during preload:

for (let i = 0; i < TOTAL_FRAMES; i++) {
  const img = new Image();
  img.decoding = "async";
  img.src = `/frames/frame-${String(i + 1).padStart(3, "0")}.jpg`;
  img.onload = () => {
    imagesRef.current[i] = img;
    if (!firstLoaded) { firstLoaded = true; setReady(true); }
  };
}

The component renders as soon as the first frame loads — the user sees the first frame while remaining ones trickle in.

Scroll → frame index mapping

The target frame index is a simple linear interpolation of scroll position:

const onScroll = () => {
  const max = document.documentElement.scrollHeight - window.innerHeight;
  targetRef.current = (window.scrollY / max) * (TOTAL_FRAMES - 1);
};

targetRef is a ref, not state — updating it doesn't trigger a re-render. The render loop reads it on every animation frame.

The RAF render loop with lerp

The animation loop interpolates the current frame toward the target frame each tick:

const tick = () => {
  const diff = targetRef.current - currentRef.current;
  currentRef.current += diff * 0.18;
  // Snap when close enough to avoid endless micro-updates
  if (Math.abs(diff) < 0.05) currentRef.current = targetRef.current;
  draw(Math.round(currentRef.current));
  rafRef.current = requestAnimationFrame(tick);
};

The 0.18 lerp factor controls the "inertia" — higher values snap faster, lower values feel more floaty. 0.18 felt right for a cinematic background.

Cover-fit drawImage

Browsers don't have a built-in "object-cover" for canvas. I implemented it manually:

const draw = (idx: number) => {
  const img = imagesRef.current[idx];
  if (!img) return;
  const { width: cw, height: ch } = canvas;
  const { naturalWidth: iw, naturalHeight: ih } = img;
  const scale = Math.max(cw / iw, ch / ih);
  ctx.drawImage(
    img,
    (cw - iw * scale) / 2,  // center horizontally
    (ch - ih * scale) / 2,  // center vertically
    iw * scale,
    ih * scale
  );
};

This ensures frames always fill the viewport without letterboxing, regardless of aspect ratio.

DPI awareness

Without accounting for devicePixelRatio, the canvas looks blurry on retina screens. The size handler multiplies by DPR:

const setSize = () => {
  const dpr = window.devicePixelRatio || 1;
  canvas.width  = window.innerWidth  * dpr;
  canvas.height = window.innerHeight * dpr;
  canvas.style.width  = `${window.innerWidth}px`;
  canvas.style.height = `${window.innerHeight}px`;
  ctx.scale(dpr, dpr);
};

Performance notes

  • getContext("2d", { alpha: false }) skips alpha channel compositing — measurably faster for opaque backgrounds
  • The lerp snapping (< 0.05 threshold) prevents endless sub-pixel animation when the user stops scrolling
  • All image refs are stored in a plain array, not React state — zero re-renders during animation

The result is a 60fps scroll-driven animation that uses no WebGL, no video, and no third-party animation libraries.