There is a particular kind of web animation that feels less like an embedded video and more like part of the interface itself. The user scrolls the page, the camera moves around an object, and each turn of the wheel reveals the next moment of the scene. The animation feels like an extension of the interaction with the page rather than a separate clip.
This effect is usually built with image sequences — a series of individual images shown one at a time. The frames are typically drawn on a canvas, while the current frame is connected to scrolling, the cursor, or a regular timer. This article explains how the approach works, how it differs from ordinary video, and what to consider in a real project.
Many polished product presentations use this technique. To get a quick feel for the idea, take a look at Pear, the studio work by Jextures, the Apple AirPods Pro page, and the interactive ScrollSequence Beauty example.
What are image sequences
It helps to compare this approach with ordinary video first. In a regular video, the browser receives a compressed media stream and decodes it as playback progresses. With image sequences, every moment is represented by a separate file in advance.
frame-0001.jpg → frame-0002.jpg → frame-0003.jpg → ...
One scene can be represented as a set of individual frames: the robot's arm changes position gradually from frame to frame.
The code selects the required frame and calls drawImage(). This lets you connect the image directly to scrolling, stop the scene at any point, or show a specific frame immediately without seeking through a media stream.
That freedom has a clear cost: more requests and higher memory usage. The frame files may weigh more than a short video, and after decoding they take considerably more memory than they do on disk. Before choosing the approach, decide what matters more for the scene: precise control over every frame or simple video playback.
Why video is not always the right fit
video is usually a better choice when the scene has sound, contains a large number of frames, and the user simply needs to watch a clip. The browser manages buffering and streaming, so the developer does not have to load and store every frame manually.
Choose image sequences when exact gesture-to-frame control, stopping at an arbitrary point, visual effects, or transparency matter more. In other words, video is better for sequential viewing, while a sequence — for controlling a scene.
| Task | Suitable tool |
|---|---|
| A clip with an audio track | video |
| A long scene with hundreds or thousands of frames | video |
| The frame should follow scrolling | canvas and image sequences |
| The scene must pause at any point | canvas and image sequences |
| 3D lighting, particles, and geometry are required | WebGL or a 3D engine |
At the same time, canvas alone does not guarantee 60 FPS. The result depends on frame resolution, decoding speed, the amount of work done for each frame, and the particular device. With that in mind, let us look at the building blocks of a typical sequence.
How a sequence works
Broken down into parts, this kind of sequence is fairly simple. There is no special magic involved: prepare the frames, load them, select the current one, and draw it at the right time.
- A set of frame URLs.
- A preloader that turns URLs into ready-to-draw images.
- A
canvasfor drawing the current frame. - An animation clock such as
requestAnimationFrame(). - A source of progress such as page scrolling.
One practical setup — prepare three sets of 24 frames: 1920x1080 for large screens, 1080x1080 for tablets, and 360x640 for phones. The animation logic chooses a set based on the viewport width, loads only that variant, observes size changes with ResizeObserver, and draws the frames at 20 FPS. It is not the only possible setup, but it demonstrates the overall logic well.
This separation matters more than the framework. State can live in a separate module or in a closure regardless of the stack. The rest of the examples can therefore use plain JavaScript without being tied to a particular library.
A minimal implementation
Let us start with a simple player. It loads the frames, adjusts the internal canvas size for devicePixelRatio, and then starts playback with requestAnimationFrame().
1. Preparing the canvas and frame URLs
const canvas = document.querySelector('#sequence');
const context = canvas.getContext('2d');
const frameUrls = Array.from({ length: 24 }, (_, index) => {
const number = String(index + 1).padStart(2, '0');
return `/frames/product_${number}.jpg`;
});getContext('2d') returns the 2D context used to clear the canvas, change its coordinate system, and draw images.
The markup only needs a canvas with the sequence ID. The JavaScript below is split into small parts so the role of each one is easier to understand.
2. Loading the images
const loadImage = url =>
new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = reject;
image.src = url;
});
const frames = await Promise.all(frameUrls.map(loadImage));The Image constructor creates an image without adding it to the document. Once the resource loads, onload fires, and if loading fails — onerror, so the function turns each frame load into a promise.
Promise.all() waits for every promise to settle and returns the results in the same order as the original URL array.
3. Drawing the current frame
let frame = 0;
const drawFrame = () => {
const image = frames[frame];
const scale = Math.min(
canvas.clientWidth / image.naturalWidth,
canvas.clientHeight / image.naturalHeight,
);
const width = image.naturalWidth * scale;
const height = image.naturalHeight * scale;
const x = (canvas.clientWidth - width) / 2;
const y = (canvas.clientHeight - height) / 2;
context.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
context.drawImage(image, x, y, width, height);
};Here, clientWidth and clientHeight are the canvas size in CSS pixels, while naturalWidth and naturalHeight — the source image dimensions. Comparing these values tells the code how to fit the frame inside the container. drawImage() then renders it without distorting its proportions.
drawFrame() — a local function from this example, not a built-in Web API. It takes the current item from the frames array, calculates the scale, and passes the image to context.drawImage().
4. Canvas size and the animation loop
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(canvas.clientWidth * dpr);
canvas.height = Math.round(canvas.clientHeight * dpr);
context.setTransform(dpr, 0, 0, dpr, 0, 0);
drawFrame();
};
let lastTime = 0;
const frameDuration = 1000 / 20;
const animate = time => {
if (time - lastTime >= frameDuration) {
frame = (frame + 1) % frames.length;
drawFrame();
lastTime = time - ((time - lastTime) % frameDuration);
}
requestAnimationFrame(animate);
};
new ResizeObserver(resize).observe(canvas);
resize();
requestAnimationFrame(animate);ResizeObserver is needed because the canvas can change size when its container changes, even if the viewport itself stays the same. The observer reacts to changes in the element, while resize() updates the canvas bitmap size and redraws the current frame. setTransform() scales the coordinate system for the DPR, so drawing coordinates can remain in CSS pixels.
requestAnimationFrame() runs before the page is repainted and provides a timestamp. It is more convenient for animation than setInterval(), but each callback runs only once, so it must be requested again to continue the loop.
The time - lastTime check keeps playback speed independent of the monitor refresh rate. Without it, a sequence could run twice as fast on a 120 Hz display as on a 60 Hz display. delta-time also prevents the loop from trying to catch up with stale frames after the browser misses an update.
Connecting the sequence to scrolling
For a scroll-driven animation, no timer is required. Convert the section position to a number between 0 and 1, then turn that value into a frame index.
const section = document.querySelector('.sequence-section');
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
const getProgress = () => {
const bounds = section.getBoundingClientRect();
const distance = Math.max(section.offsetHeight - innerHeight, 1);
return clamp(-bounds.top / distance, 0, 1);
};
const renderFromScroll = () => {
const progress = getProgress();
frame = Math.round(progress * (frames.length - 1));
drawFrame();
};
addEventListener('scroll', renderFromScroll, { passive: true });
addEventListener('resize', renderFromScroll);getBoundingClientRect() returns the element position relative to the viewport. The passive option tells the browser that the scroll handler will not call preventDefault(), so scrolling does not have to wait for the handler.
There is one important detail: the scroll handler may run more often than the browser can draw. In production code, it is better to store the latest scroll position and call drawFrame() no more than once per requestAnimationFrame(). This combines several rapid scroll events into one screen update.
let scrollFrameRequested = false;
const scheduleScrollRender = () => {
if (scrollFrameRequested) return;
scrollFrameRequested = true;
requestAnimationFrame(() => {
scrollFrameRequested = false;
renderFromScroll();
});
};
addEventListener('scroll', scheduleScrollRender, { passive: true });In scroll handlers, preventDefault() cancels the browser’s default action. Therefore, a performant scroll handler should explicitly use passive: true when cancellation is not needed.
Preloading and the network
Promise.all(frameUrls.map(loadImage)) is convenient for a small set, but it is a poor fit for a large scene. If hundreds of requests are sent at once, they compete with CSS, fonts, and the page’s primary content.
For heavier sequences, load frames in batches.
const loadInBatches = async (urls, batchSize = 4) => {
const images = [];
for (let index = 0; index < urls.length; index += batchSize) {
const batch = urls.slice(index, index + batchSize);
images.push(...(await Promise.all(batch.map(loadImage))));
}
return images;
};In practice, a staged approach works well.
- Load the first frame so the block does not look empty.
- Load a few frames that the user will see at the beginning.
- Load the remaining frames after the main content is available.
Choose the file format for the scene itself. JPEG works well for photographic frames, while WebP and AVIF can often reduce their size. PNG is useful only when transparency or sharp flat colors are genuinely important. Every frame in one sequence should have the same dimensions and crop. The ScrollSequence documentation also recommends reducing resolution ahead of time and choosing the compression level for the specific task.
Responsive quality

The same visual material can be prepared for different container sizes.
Using one frame set for every device almost always means unnecessary downloads on phones. It is simpler to prepare several variants in advance and choose one based on the container width and the device’s capabilities. Smaller screens get a lighter download, while larger screens keep better image quality.
| Variant | Example resolution | When to choose it |
|---|---|---|
| Desktop | 1920x1080 | A large viewport and a powerful device. |
| Tablet | 1080x1080 | A medium width or a square composition. |
| Mobile | 360x640 | A narrow screen and a limited connection. |
Choose a variant based on the container width, not only window.innerWidth. The component may live inside a modal or a column that occupies only part of the screen.
const getVariant = width => {
if (width <= 560) return 'mobile';
if (width <= 1080) return 'tablet';
return 'desktop';
};devicePixelRatio makes an image sharper but also increases the buffer size. It is therefore sensible to cap it at a value such as 2 instead of multiplying a huge canvas by the high DPR of an expensive monitor. Otherwise, memory usage and render time grow almost invisibly until the device begins to slow down.
Scaling the frame

contain keeps the object visible, while cover fills the container and may crop the edges.
The contain mode shows the whole frame and leaves empty space when the proportions do not match. The cover mode fills the container but may crop the edges.
const scale =
objectFit === 'cover'
? Math.max(width / image.naturalWidth, height / image.naturalHeight)
: Math.min(width / image.naturalWidth, height / image.naturalHeight);Visual websites often add a gradient or vignette over the canvas. This can hide the composition edges neatly, but the layer must not intercept pointer events or replace a correct object-fit implementation when the user needs to examine the product. In short, a decorative layer should support the scene rather than interfere with the interaction.
The CSS object-fit property uses the same contain and cover modes.
Memory and decoding
JPEG may take very little space on disk, but the browser draws the already-decoded image. The file size therefore does not tell you how much memory the sequence will actually need at runtime. A quick estimate for an uncompressed RGBA frame.
memory per frame ≈ width × height × 4 bytesSixty frames at 1920x1080 require about 475 MiB, excluding the browser’s internal overhead. So «sixty small JPEGs» do not mean that the memory requirement will be close to the combined file size.
When the number of frames grows, there are several practical options.
- Keep only a window of frames around the current position in memory.
- Use multiple resolutions and load the required variant.
- Reduce the resolution of the
canvasand the frames. - Switch to
videowhen frame-by-frame control is no longer needed. - Consider
createImageBitmap()after measuring on target devices.
createImageBitmap() is not a magic performance switch. It can move decoding into a browser-appropriate mechanism, but it adds its own costs and does not solve the total memory problem. Add it only after measuring on the devices that matter.
Accessibility and motion control
A large animation should not be the only way to understand a product. Even when the effect looks impressive, provide a short description, a static first frame, or a text block with the essential information nearby.
Respect the user’s prefers-reduced-motion preference, which indicates that they may want less motion.
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduceMotion) {
frame = 0;
drawFrame();
} else {
requestAnimationFrame(animate);
}matchMedia() checks whether the current viewport matches the media query. If the user has enabled reduced motion, the example shows the first frame and does not start the animation loop.
Autoplay should also remain under the user’s control. Long scenes benefit from pause and resume buttons. For a scroll-driven version, it is useful to offer a static frame so the user can read adjacent text without being forced to scroll.
Debugging
Do not check only the average FPS. Test several scenarios: the first load, resizing the window, rotating the phone, returning to the tab, and entering the section again. Memory and synchronization problems often appear in exactly these situations.
In Chrome DevTools, open the Performance panel and record several of these scenarios. Look for long Animation Frame Fired tasks, red dropped-frame markers, JavaScript execution time, and rendering load. The Frames section helps determine whether a frame was rendered, partially displayed, or skipped. MDN’s guide to animation performance covers the general principles of measuring frame rate and rendering cost.
Always test the sequence on a real phone. A desktop MacBook may render a large canvas without trouble, while a budget Android phone can hit its limits quickly. Watch network traffic, the number of decoded images, and memory usage on the devices your audience actually uses.
Conclusion
Image Sequences — not simply «video made from pictures», but a way to make time part of the interface. This approach is especially effective for product stories where the user controls the moment of the scene with a scroll or gesture.
A good implementation starts with simple questions rather than a library choice: how many frames are actually needed, what resolution is enough, can they be loaded progressively, and what will the user see when animation is disabled? After that, connect the frames to canvas, synchronize updates with requestAnimationFrame(), and test the result on real devices.
