Apple-style image sequence scroll animation in WordPress
Apple's product pages have a trick people want to copy the moment they see it: you scroll, and the product turns, opens up or comes apart โ smoothly, and exactly as far as you scrolled. It is not a video. It is a series of still images, frames, drawn one after another onto a canvas, with the scroll position choosing which one. This guide covers how it works, how to make the frames, the code to build one in WordPress, and what to watch so it does not cost your visitors more than it shows them.
How it works
There are three parts:
- A tall section. Its height is the scroll distance: the taller it is, the slower the animation plays. Three or four screens is typical.
- A canvas that stays put. Inside the section, a canvas with
position: stickystays on screen while the section scrolls past. - Scroll progress picks the frame. How far through the section the visitor has scrolled, from 0 to 1, times the number of frames, is the frame to draw.
Why frames and not a video? A video can follow the scroll too, but browsers jump to the nearest keyframe when they seek, so an ordinary video stutters or lags when it is scrubbed. Frames are exact: frame 57 is always frame 57. The price is weight โ every frame is an image to download โ and most of what follows is about keeping that price down.
Making the frames
Start from a short video, such as a 3D render or a product on a turntable, and cut it into images with ffmpeg:
mkdir frames
ffmpeg -i input.mp4 -vf "fps=30,scale=1920:-2" -c:v libwebp -quality 80 frames/hero_%04d.webp
That writes hero_0001.webp, hero_0002.webp and so on: 30 frames for
every second of video, 1920 pixels wide, as WebP. Some numbers to aim for:
- 60 to 150 frames. Fewer than 60 looks choppy over a long scroll; more
than 150 rarely looks better and always weighs more. Lower the
fpsto get fewer. - The width of the screen, not more. 1920 pixels covers most desktops. Phones need far less.
- WebP, or a well-compressed JPEG. At 60โ150 KB a frame, 120 frames come to 7โ18 MB. That is a lot for a phone, which is why how the frames load matters as much as the code that draws them.
Upload the frames to the Media Library together. WordPress keeps their file
names, so hero_0001.webp ends up at an address such as
/wp-content/uploads/2026/10/hero_0001.webp โ open one in the library to
see yours. WordPress also makes its usual smaller sizes of every frame,
which turns out to be useful for phones.
The code
Here is a minimal version that works as it is. The markup goes on the page, in an HTML block or your page builder's HTML widget. Give the canvas the size of your frames:
<div class="frames">
<canvas class="frames-canvas" width="1920" height="1080"></canvas>
</div>
The CSS makes the section tall and keeps the canvas on screen:
.frames {
height: 300vh; /* the scroll distance */
background: #000;
}
.frames-canvas {
position: sticky;
top: 0;
display: block;
width: 100%;
height: 100vh;
height: 100svh;
object-fit: contain;
}
The JavaScript draws the frame that matches the scroll. Set count to the
number of frames and src to where they are:
const section = document.querySelector('.frames');
if (section) {
const canvas = section.querySelector('canvas');
const context = canvas.getContext('2d');
const count = 120;
const src = (i) => `/wp-content/uploads/2026/10/hero_${String(i + 1).padStart(4, '0')}.webp`;
const frames = [];
let shown = -1;
const ready = (i) => frames[i] && frames[i].complete && frames[i].naturalWidth > 0;
const draw = () => {
const rect = section.getBoundingClientRect();
const distance = rect.height - window.innerHeight;
const progress = distance > 0 ? Math.min(1, Math.max(0, -rect.top / distance)) : 0;
const wanted = Math.round(progress * (count - 1));
// Until the wanted frame arrives, draw its nearest neighbour that has.
for (let d = 0; d < count; d++) {
const i = [wanted - d, wanted + d].find(ready);
if (i !== undefined) {
if (i !== shown) {
context.drawImage(frames[i], 0, 0, canvas.width, canvas.height);
shown = i;
}
return;
}
}
};
// Every eighth frame first, then the gaps, so the whole range is covered early.
for (const step of [8, 4, 2, 1]) {
for (let i = 0; i < count; i += step) {
if (!frames[i]) {
frames[i] = new Image();
frames[i].onload = draw;
frames[i].src = src(i);
}
}
}
let queued = false;
window.addEventListener('scroll', () => {
if (!queued) {
queued = true;
requestAnimationFrame(() => {
queued = false;
draw();
});
}
}, { passive: true });
}
Two details in it do most of the work. It redraws only when the frame changes, so scrolling within one frame costs nothing. And it loads every eighth frame first, then the frames in between, drawing the nearest frame that has arrived โ so the animation works from the first second, coarse at first and smooth a moment later, instead of waiting for frame 120.
In StyleScript, the free plugin for your own CSS and
JavaScript, the CSS goes in the CSS box and the script in the
JavaScript box, loaded in the footer. The if (section) line keeps the
script harmless on every page that has no animation.
What the minimal version leaves out
A version for a real site needs a few more things, and each one is about the visitor on a phone:
- Loading only when it is needed. As written, every frame starts
downloading on every visit, even when the section is far down the page or
never reached. Start when the section comes near the screen, with an
IntersectionObserver. - Fewer, smaller frames on phones. A phone needs neither 1920-pixel frames nor all of them. WordPress' smaller size of each frame and every second or third frame look the same on a small screen at a fraction of the weight.
- A sharp canvas without waste. Size the canvas to the space it fills
times
devicePixelRatio, capped at 2: sharper than that is invisible but still costs memory. - Less motion for those who ask. Visitors who set their device to reduce motion should get one still image, not an animation that follows the scroll.
- Memory. Hundreds of decoded full-size frames do not fit in a phone's memory; keep them as images and let the browser manage them, rather than converting everything to bitmaps up front.
The no-code version
StyleScript Pro, the add-on to the free plugin, has this as a ready-made
effect: Image Sequence Animation On Scroll, set up in a form under
Appearance โ StyleScript โ Effects. You select all the frames in the
Media Library at once โ they play in file-name order โ or give the address
of numbered files with {0001} where the number goes. You place it with a
shortcode or the StyleScript effect block, or in an element already on
the page.
It does the list above: frames load once the effect is near the screen, in passes, with the nearest frame drawn until each one arrives; phones get every second, third or fourth frame (your choice) in WordPress' smaller size; the canvas is at most twice the screen's pixels and redrawn only on a new frame; visitors who prefer less motion see a still image; and the space is reserved before anything loads, so nothing on the page moves. It tells you when the frames together weigh more than we recommend, previews on any page before it goes live, and if you ever want the code for yourself, Detach hands it over as readable code.
Or a video that scrubs
When the frames would be too heavy, one video can do the same job โ if it is encoded for it. Seeking is only smooth when keyframes are close together, so the video needs a keyframe every few frames, no sound, and its index at the start of the file:
ffmpeg -i input.mp4 -an -vf "scale=-2:1080" -c:v libx264 -crf 23 -preset slow -g 5 -keyint_min 5 -sc_threshold 0 -pix_fmt yuv420p -movflags +faststart scrub.mp4
StyleScript Pro's Video Scrubbing On Scroll effect plays a video like that as visitors scroll, smooths the seeking, and shows its poster image to visitors who prefer less motion.
Keep your custom code in one place
StyleScript holds your CSS, JavaScript and header/footer code apart from the theme, prints each where it belongs, and keeps every version you save.