← All writing
· 4 min read

Why I rebuilt my portfolio hero as a canvas

The story of a flicker, a 119-frame WebP sequence, and learning the hard way that <img> src-swapping is not how Apple does it.

PerformanceWebAnimation

If you've seen Apple's iPhone product pages, you know the scroll-driven animation where the device rotates as you scroll. I wanted that for my landing hero — a smooth head-turn synced to scroll position, 119 frames, no jank.

Version 1 was naive: one `<img>` element, swap `src` every scroll tick. It worked... ish. On desktop, mostly smooth. On mobile, every other scroll produced a brief blank frame. Flicker.

What's actually happening

When you change an `<img>`'s `src`, the browser doesn't atomically swap pixels. It enters a state where the new image is being loaded (even from cache) and decoded. Between the old paint and the new one, there's a window — sometimes a few milliseconds, sometimes longer — where the element renders nothing.

On a slow Android, that window stretches. On a fast Mac, you barely notice. But the inconsistency is the problem — it can't be the right answer.

The canvas trick

Apple's solution is what I copied: render to a `<canvas>` element, and draw pre-loaded `HTMLImageElement`s via `ctx.drawImage()`. The key property: drawImage is synchronous with the next paint. There is no in-between state.

Preload all the frames into an array of Image objects. On scroll, compute the target index, call drawImage with that image, done. No src-swapping, no flicker, no decode timing weirdness.

The DPR detail

One thing the tutorials skip: device pixel ratio. If you set `canvas.width = 800` on a retina screen, the canvas internally has 800 pixels but stretches across 1600 device pixels — blurry.

The fix: set `canvas.width = cssWidth * dpr`, then `ctx.scale(dpr, dpr)` so all your drawing math stays in CSS pixels. Cap DPR at 2 — beyond that is just bandwidth waste with no visible difference.

What I'd do differently

Convert source frames to WebP from the start. I had 75MB of PNG before I converted to WebP and saved 6×. Don't ship 800KB-per-frame PNGs in 2026.

— Saurabh