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