Apple is well-known for the sleek animations on their product pages. For example, as you scroll down the page products may slide into view, MacBooks fold open and iPhones spin, all while showing off the hardware, demonstrating the software and telling interactive stories of how the products are used.
Just check out this video of the mobile web experience for the iPad Pro:
A lot of the effects that you see there aren’t created in just HTML and CSS. What then, you ask? Well, it can be a little hard to figure out. Even using the browser’s DevTools won’t always reveal the answer, as it often can’t see past a <canvas>
element.
Let’s take an in-depth look at one of these effects to see how it’s made so you can recreate some of these magical effects in our own projects. Specifically, let’s replicate the AirPods Pro product page and the shifting light effect in the hero image.
The basic concept
The idea is to create an animation just like a sequence of images in rapid succession. You know, like a flip book! No complex WebGL scenes or advanced JavaScript libraries are needed.
By synchronizing each frame to the user’s scroll position, we can play the animation as the user scrolls down (or back up) the page.
Start with the markup and styles
The HTML and CSS for this effect is very easy as the magic happens inside the <canvas>
element which we control with JavaScript by giving it an ID.
In CSS, we’ll give our document a height of 100vh and make our <body>
5⨉ taller than that to give ourselves the necessary scroll length to make this work. We’ll also match the background color of the document with the background color of our images.
The last thing we’ll do is position the <canvas>
, center it, and limit the max-width
and height
so it does not exceed the dimensions of the viewport.
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%);
}
Right now, we are able to scroll down the page (even though the content does not exceed the viewport height) and our <canvas>
stays at the top of the viewport. That’s all the HTML and CSS we need.
Let’s move on to loading the images.
Fetching the correct images
Since we’ll be working with an image sequence (again, like a flip book), we’ll assume the file names are numbered sequentially in ascending order (i.e. 0001.jpg, 0002.jpg, 0003.jpg, etc.) in the same directory.
We’ll write a function that returns the file path with the number of the image file we want, based off of the user’s scroll position.
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`
)
Since the image number is an integer, we’ll need to turn it in to a string and use padStart(4, '0')
to prepend zeros in front of our index until we reach four digits to match our file names. So, for example, passing 1 into this function will return 0001.
That gives us a way to handle image paths. Here’s the first image in the sequence drawn on the <canvas>
element:
As you can see, the first image is on the page. At this point, it’s just a static file. What we want is to update it based on the user’s scroll position. And we don’t merely want to load one image file and then swap it out by loading another image file. We want to draw the images on the <canvas>
and update the drawing with the next image in the sequence (but we’ll get to that in just a bit).
We already made the function to generate the image filepath based on the number we pass into it so what we need to do now is track the user’s scroll position and determine the corresponding image frame for that scroll position.
Connecting images to the user’s scroll progress
To know which number we need to pass (and thus which image to load) in the sequence, we need to calculate the user’s scroll progress. We’ll make an event listener to track that and handle some math to calculate which image to load.
We need to know:
- Where scrolling starts and ends
- The user’s scroll progress (i.e. a percentage of how far the user is down the page)
- The image that corresponds to the user’s scroll progress
We’ll use scrollTop
to get the vertical scroll position of the element, which in our case happens to be the top of the document. That will serve as the starting point value. We’ll get the end (or maximum) value by subtracting the window height from the document scroll height. From there, we’ll divide the scrollTop
value by the maximum value the user can scroll down, which gives us the user’s scroll progress.
Then we need to turn that scroll progress into an index number that corresponds with the image numbering sequence for us to return the correct image for that position. We can do this by multiplying the progress number by the number of frames (images) we have. We’ll use Math.floor()
to round that number down and wrap it in Math.min()
with our maximum frame count so it never exceeds the total number of frames.
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)
);
});
Updating <canvas> with the correct image
We now know which image we need to draw as the user’s scroll progress changes. This is where the magic of <canvas> comes into play. <canvas>
has many cool features for building everything from games and animations to design mockup generators and everything in between!
One of those features is a method called requestAnimationFrame
that works with the browser to update <canvas>
in a way we couldn’t do if we were working with straight image files instead. This is why I went with a <canvas>
approach instead of, say, an <img>
element or a <div>
with a background image.
requestAnimationFrame
will match the browser refresh rate and enable hardware acceleration by using WebGL to render it using the device’s video card or integrated graphics. In other words, we’ll get super smooth transitions between frames — no image flashes!
Let’s call this function in our scroll event listener to swap images as the user scrolls up or down the page. requestAnimationFrame
takes a callback argument, so we’ll pass a function that will update the image source and draw the new image on the <canvas>
:
requestAnimationFrame(() => updateImage(frameIndex + 1))
We’re bumping up the frameIndex
by 1 because, while the image sequence starts at 0001.jpg, our scroll progress calculation starts actually starts at 0. This ensures that the two values are always aligned.
The callback function we pass to update the image looks like this:
const updateImage = index => {
img.src = currentFrame(index);
context.drawImage(img, 0, 0);
}
We pass the frameIndex
into the function. That sets the image source with the next image in the sequence, which is drawn on our <canvas>
element.
Even better with image preloading
We’re technically done at this point. But, come on, we can do better! For example, scrolling quickly results in a little lag between image frames. That’s because every new image sends off a new network request, requiring a new download.
We should try preloading the images new network requests. That way, each frame is already downloaded, making the transitions that much faster, and the animation that much smoother!
All we’ve gotta do is loop through the entire sequence of images and load ‘em up:
const frameCount = 148;
const preloadImages = () => {
for (let i = 1; i < frameCount; i++) {
const img = new Image();
img.src = currentFrame(i);
}
};
preloadImages();
Demo!
A quick note on performance
While this effect is pretty slick, it’s also a lot of images. 148 to be exact.
No matter much we optimize the images, or how speedy the CDN is that serves them, loading hundreds of images will always result in a bloated page. Let’s say we have multiple instances of this on the same page. We might get performance stats like this:

That might be fine for a high-speed internet connection without tight data caps, but we can’t say the same for users without such luxuries. It’s a tricky balance to strike, but we have to be mindful of everyone’s experience — and how our decisions affect them.
A few things we can do to help strike that balance include:
- Loading a single fallback image instead of the entire image sequence
- Creating sequences that use smaller image files for certain devices
- Allowing the user to enable the sequence, perhaps with a button that starts and stops the sequence
Apple employs the first option. If you load the AirPods Pro page on a mobile device connected to a slow 3G connection and, hey, the performance stats start to look a whole lot better:

Yeah, it’s still a heavy page. But it’s a lot lighter than what we’d get without any performance considerations at all. That’s how Apple is able to get get so many complex sequences onto a single page.
Further reading
If you are interested in how these image sequences are generated, a good place to start is the Lottie library by AirBnB. The docs take you through the basics of generating animations with After Effects while providing an easy way to include them in projects.
Just thinking … would it be possible to load all single images combined in a video file? And then synchronize the play position with the scrolling? Would this be better or worse for total file size? Would it result in similar or worse image quality? Would it be animated more fluid than the solution with an image sequence?
I was thinking the same thing! It will be much better to download only one video instead of downloading 148 images, it’s like a huge terrible gif with 148 http connections.
I tried replicating this effect using a video element and synchronizing the currentTime of the video to the scroll position, but doing this with an mp4 resulted in severe lag. WebM Video worked a lot better, but the format is still incompatible with safari. I made a CodePen, so you can try for yourself: https://codepen.io/Maltsbier/pen/dyYmGGq
It’s certainly possible to use a single video file and synchronize that to the user’s scroll position. I suspect download performance is roughly similar. Since I don’t have Apple’s source file I can’t do an A/B test to see which one has the bigger total file size, 148 images or a single video. Zahar is right in pointing out that it would decrease the number of http requests made but with HTTP/2 these can all be done one a single TCP connection so I suspect there is not much performance to be gained there.
The most important downside to video, as Malte demonstrated, is lag. The video html tag can not utilize
requestAnimationFrame()
which is the most important factor in making this effect as smooth as possible. requestAnimationFrame uses your video card to render it instead of the CPU and is limited to 60fps by the browser so your machine can keep up.In the past, I had tried a bunch of approaches on the same, and some of them work better than others. In summary, a direct video frame-seek wouldn’t work as efficiently and smoothly with scroll as you’d imagine (frame drops happen; browsers internally try to optimise avoiding repeated work) especially on mobile devices. However, you could still find multiple nifty ways to unpack a video into frames directly within the browser and use them to create your scrolling animation. Videos generally compresses better, but distinct images demand much less compute work on the browser, and with careful sequencing and preloading, it’s possible that you can get the animation sequence started much faster than a video unpack. Aspects of image quality and total size really can depend on the fidelity of the animation you’re gunning for (no. of frames, resolution).
I hope you find this article interesting (includes demos and source code): https://www.ghosh.dev/posts/playing-with-video-scrubbing-animations-on-the-web/
To test the size difference, I generated images and videos myself using blender, and the results are pretty clear: 90 images take up 56MB, while a three second video with 30fps takes only 1.92MB.
Contrary to what Jurn van Wissen stated, requestAnimationFrame() does not decide what get’s accelerated by the GPU or not, that’s up to the browser. But it makes updates that should happen every frame “smoother”, because it only get’s called before every repaint, thus saving processing power by not update more often than needed.
This is of course used by the video element too, we don’t need to set it’s currentTime more than every frame.
What I think is causing the lag is the interpolation between the video keyframes. As you can see, the upper two videos, rendered with a keyframe interval of 1 are way smoother than the bottom one(*), rendered with a keyframe interval of 20.
At least on my pc. On my phone, all of the videos stuttered noticeably, with the VP9/WEBM video performing the best.
You might have more luck combining the techniques. Download as a video, render the frames to an offscreen canvas so you have them extracted and ready to draw to the on-screen canvas on scroll as per the image technique
@Malte Janßen: Correct, I did not write that clear enough. It is not requestAnimationFrame() but the browser that decides what is accelerated by the GPU. Using requestAnimationFrame() is just one of the options where the browser is able to accelerate it by using the GPU. There are many ways to create this animation and some of the options lean heavily on a single CPU core which could make this animation less smooth.
@Martin Ansty interesting idea. That raises a few questions for me.
If you were to take your 3 seconds of video and render them out to individual images would the images be that much bigger?
What about archiving all of the images and unzipping it using javascript? You could load a single file, unzip it, and render out the individual frames.
Would it be possible to make it scroll on the div height instead of the full page height?
Demo doesn’t work on Safari iOS 12
Interesting, can you tell me what’s happening? If it doesn’t work in the Codepen demo, does it work for you on Apple’s own website?
I’ve tested it on multiple iOS devices and it’s working smoothly on all of them. They are running iOS13 but as Apple has been using this effect on their website since ~2014 it should work fine on older iOS versions as well.
I have also facing same issue in IOS safari it doesn’t work in it.
When we scroll top to bottom there is no change in any images.
Just please oh please don’t hijack the scrolling like the black trash can Mac Pro page did.
Yeah, it is horrible. I also feel like someone hijacks my scrollwheel and prevents me from actually scrolling!
Could I use instead of 148 images a huge image (sprite) so its only 1 http request? And move something like “steps” function in css animation but in canvas?
Absolutely!
Here is an example of using a sprite on the canvas and animating the position: https://dev.to/martyhimmel/animating-sprite-sheets-with-javascript-ag3
In this situation it’s probably not ideal to use this method as you would have to download the entire file before you can show the first frame to the user. That means instead of downloading a single 31kb image, you need to download all 148 of them which would slow down the initial load. This would negate any performance benefit you might get from decreasing the amount of http requests.
Would you mind clearing up the conflation between
requestAnimationFrame()
and webgl? The article makes out that using rAF causes the images to be rendered on the GPU with webgl, but the canvas context is plain old 2d. Of course rAF is still useful to have the browser update the canvas during each render frame; but it is unrelated to webgl.There is also the idea of just using a video and flipping it onto it’s end and doing some time index manipulation to play it backwards and forward. I made a demo here:
https://forums.tumult.com/t/playing-video-in-reverse/17928?u=maxzieb
It isn’t perfect but could be worked on.
Thanks a lot for the article, this is really a great technique and you’ve made it very clear how to implement.
One small but significant issue I’ve found is that setting
img.src = currentFrame(index)
insideupdateImage
makes a new network request each time, ignoring the browser cache (at least in Chrome).My solution is to put the
img
s in an array when preloading, and then draw the image from the cache:I have a Canvas Animation made and exported from Adobe Animate. It’s much much lighter in size as it’s mostly SVG also, Animate optimized the animation for performance. The only thing I want is to synchronize it with the scroll position. How can I do that?
With React:
https://codepen.io/AliKlein/pen/dyOqrEB
Feedback welcome, especially performance improvements.
On Firefox, if I interact with the codepen above, then switch to another tab in the browser for a minute or so and then scroll through the animation here again, it studders. Can anyone else reproduce the problem?
Because at the moment this problem prevents me from using this effect on my website.
It’s because Jurn has still some unnecessary network requests in its code (see what Jonathan Land wrote).
I ende with this solution an it behaves pretty smooth:-)
Thanks for the article and explanations!
I chopped up my own video into images and used this code to create the scroll effect. It works great when I dev locally, but when I deploy, with Netlify in this case, it becomes choppy.
I don’t know much about storage of this level of assets on the web, but what could be causing the choppiness once deployed? I’m using 212 images that are ~30kb. What could I do differently to potentially fix? Could it simply be because I’m using the free Netlify deployment that doesn’t support this volume of assests? Thanks!!
Is there a way we could add an easing effect to the images when they render on canvas so it looks like the animation doesn’t stop on scroll?
Probably play with opacity in CSS or manually by making first and last few frames fade in
How would you write this javascript to only start animating the canvas when its in view?
This appears to always constantly calculate the scroll fraction throughout the page and update the image frame
Never mind, you put the canvas in a div and make the canvas sticky, now it updates based on scroll within the div, thanks to the other example in the comments! :)
I can’t get this to work. Any clues – I’ve put it in a div and made the canvas sticky but still does it on percentage of page scroll :(
If you want to use it in combination with a scroll library (eg. Locomotive scroll js) how you control that having a fixed canvas??
I want the scroll effect to start when scroll reaches the specific block. What changes i have to make in code to make it happened?
Can anyone in this thread help me by chance? I’ve built one of these and got it working nicely, however I am getting a flashing/strobing between images as I scroll. Does anyone have any idea how I could remedy it?
the image sequence is playing for the full height of the page, can we do like for 1000vh for example, all transitions are done and after that, we add extra content outside of the canvas.
the problem I’m facing is that it’s getting played for all heights of a webpage, so I’m not able to add content after the video ends.
if I increase or decrease the height of a webpage, frames show according to that but it shows up till the end of a webpage
can you guys help, please?
I want to use this animation on 3 sections on same page.
What changes I have to make ?
Pls help
The actual Apple AirPod Pro website uses
<section> tags to fit it’s several scrolling-tied animation sections, so I’d assume that you’d just do that. It would change how you’re exactly using js to manipulate the css of each section, though I can’t figure out how you’d do it
Fwiw, Apple is using synchronized video scrolling in their newer product pages, but this is still great to learn.
Thanks for the article it’s very helpful. I have a question. after the end of this image sequence how can I move the section?
after loading next frame how to remove all other frames from screen ?
Hey Jurn. This is a great example, thanks for providing this. I am going to be working on something similar and I was wondering if you had any tips on how many images to export for each second of video?
Thanks for your help!