The Scroll-Synced Canvas: Recreating Apple-Style Hero Animations
Apple's product pages have a signature trick: as you scroll, a product's hero image shifts and transforms in sync with your scroll position. It looks like video playing frame-by-frame, driven entirely by how far you've scrolled down the page. The effect feels like it must require complex 3D rendering, but the reality is much simpler—it's just a sequence of images drawn on a <canvas> element, updated with every scroll tick.
Here's how to build that effect from scratch, using the light-shifting hero image from the AirPods Pro page as our reference point.
Setting Up the Scroll Container
The effect requires two things: a page tall enough to scroll through, and a fixed, centered <canvas> that stays in view while you scroll. The HTML is minimal—just a <canvas> with an ID for JavaScript to grab onto.
<canvas id="hero"></canvas>
In CSS, we set the document height to 100vh and make the <body> five times that height to create enough scroll room. We also ensure the canvas is centered and constrained so it never exceeds the viewport dimensions, regardless of screen size. The canvas remains in the viewport—the body is what scrolls underneath it.
html {
height: 100vh;
}
body {
background: #000;
height: 500vh;
}
canvas {
position: fixed;
left: 50%;
top: 50%;
max-height: 100vh;
max-width: 100vw;
transform: translate(-50%, -50%);
}
Generating Image Paths
The animation works like a digital flip book: a numbered sequence of image files (0001.jpg, 0002.jpg, and so on), each one a single frame of the animation. Scroll up, and you play the frames forward; scroll back, and you reverse them.
We'll write a simple utility that takes an index number and returns the correct file path, zero-padded to match the four-digit naming convention:
const currentFrame = index => (
`https://www.apple.com/105/media/us/airpods-pro/2019/1299e2f5_9206_4470_b28e_08307a42f19b/anim/sequence/large/01-hero-lightpass/${index.toString().padStart(4, '0')}.jpg`
)
Passing 1 to that function yields 0001. This keeps the logic of mapping scroll position to a specific frame clean and separated.
Mapping Scroll Position to a Frame
To pick the right frame, we need three values: where scrolling starts, where it ends, and where the user currently is. Using scrollTop gives us the current vertical position. The maximum scroll value is the document's scroll height minus the window height. Dividing the current scroll position by that maximum gives us a progress ratio from 0 to 1.
That ratio then needs to be translated into an integer index that corresponds to an image in the sequence. We multiply the progress by the total number of frames, round down with Math.floor(), and clamp it so it never exceeds the last frame:
window.addEventListener('scroll', () => {
const scrollTop = html.scrollTop;
const maxScrollTop = html.scrollHeight - window.innerHeight;
const scrollFraction = scrollTop / maxScrollTop;
const frameIndex = Math.min(
frameCount - 1,
Math.floor(scrollFraction * frameCount)
);
});
Drawing Frames with requestAnimationFrame
The magic is in how we update the canvas. Using requestAnimationFrame—rather than directly swapping <img> sources—synchronizes the updates with the browser's refresh rate and enables hardware acceleration, resulting in smooth transitions between frames with no visible flashing.
The scroll event handler calculates the frameIndex and passes it to a drawing function:
requestAnimationFrame(() => updateImage(frameIndex + 1))
We add 1 to the frameIndex in that call because our scroll progress calculation starts at 0, while the image sequence starts at 0001.jpg. The drawing function handles the actual canvas update:
const updateImage = index => {
img.src = currentFrame(index);
context.drawImage(img, 0, 0);
}
The function takes the index, sets the image source, and paints the new frame onto the canvas. Every scroll movement triggers a redraw, creating the illusion of motion as the user navigates the page.
Improving Performance with Preloading
With only a basic implementation, rapid scrolling can cause lag. That's because every frame change is a new network request—each image has to download before it can be painted. Preloading the entire sequence ahead of time eliminates that gap, making fast scroll transitions seamless.
Looping through every frame and creating an Image object for each one primes the browser's cache:
const frameCount = 148;
const preloadImages = () => {
for (let i = 1; i < frameCount; i++) {
const img = new Image();
img.src = currentFrame(i);
}
};
preloadImages();
Performance Considerations
The visual payoff comes with a heavy cost. The AirPods Pro sequence used in an earlier example is 148 images. No amount of optimization or CDN speed changes the fact that hundreds of images bloat a page. On a connection that can't handle that weight, the experience degrades quickly.
Apple doesn't serve the full sequence to every visitor. For users in constrained conditions, a fallback strategy is in place:
- Single fallback image: load one static frame instead of the full animated sequence.
- Device-aware assets: serve smaller image dimensions to mobile and low-power devices.
- User-initiated playback: let the user opt into the scroll-scrubbed animation rather than loading it by default.
Apple's approach results in a much lighter initial payload for users on slower connections—still sizable, but a fraction of what the full sequence would require. That trade-off is central to making these effects work at scale on the open web.
For generating the frame sequences themselves, the Lottie library from Airbnb provides a practical starting point—its documentation walks through creating animations in After Effects that can be integrated into projects with an approachable API.



