The effect people ask for by pointing at an Apple product page is a visual that moves as you scroll and stops when you stop. This page shows the three ways it is built, with working code for each, what each one costs in weight and effort, and how to get the same result without writing any code.
Scroll down a product page of this kind and the product does something: it turns, opens up, catches the light. Stop scrolling and it stops. Scroll back up and it runs in reverse. The section holding it stays pinned to the screen while this happens, then lets go.
Developers call this a scroll-linked or scroll-scrubbed animation: the scrollbar is the playhead. A scroll-triggered animation is different: it starts when a section arrives and then plays on its own clock. Our developer guide to scroll-driven storytelling covers the distinction.
Two public reference points, both checked on November 11, 2026:
<canvas>, with the image chosen from the scroll position. In the author's words: "No complex WebGL scenes or advanced JavaScript libraries are needed." The article also notes that on a slow mobile connection Apple loaded a single fallback image in place of the sequence.<video> elements and no <canvas> element, with class names such as scroll-gallery, scroll-item-driver and scrub-scroll-container. We read the markup, not the scripts, so treat that as evidence of pinned, video-based sections and nothing more precise.So there is no single Apple technique, only three building blocks. Scrollytelling.ai is not affiliated with Apple. Apple and AirPods Pro are named here only because that is how people search for this web technique.
All three share one skeleton: a tall section (the scroll runway) with a position: sticky child that stays on screen while the section scrolls past. Progress runs from 0, when the section's top reaches the top of the screen, to 1, when its bottom reaches the bottom. Every sample was run in Chrome at 1,280 px and 390 px wide before publishing.
Export the clip as numbered stills, load them, and draw the one that matches the scroll progress. This is the method in the CSS-Tricks rebuild, and the smoothest of the three, because showing a frame is a single image draw.
<section class="scrub">
<canvas width="1280" height="720" role="img"
aria-label="The product turning"></canvas>
</section>
<style>
.scrub { height: 400vh; } /* the scroll runway */
.scrub canvas {
position: sticky; top: 0;
width: 100%; height: 100svh;
object-fit: cover;
}
</style>
<script>
const section = document.querySelector('.scrub');
const canvas = section.querySelector('canvas');
const ctx = canvas.getContext('2d');
const count = 120; // frames/0001.jpg to 0120.jpg
let shown = -1;
function draw() {
const r = section.getBoundingClientRect();
const p = -r.top / (r.height - innerHeight);
const i = Math.round(
Math.min(1, Math.max(0, p)) * (count - 1));
if (i === shown || !frames[i].naturalWidth) return;
ctx.drawImage(frames[i], 0, 0,
canvas.width, canvas.height);
shown = i;
}
const frames = Array.from({ length: count }, (_, i) => {
const img = new Image();
img.onload = draw;
img.src = 'frames/' +
String(i + 1).padStart(4, '0') + '.jpg';
return img;
});
let queued = false;
addEventListener('scroll', () => {
if (queued) return;
queued = true;
requestAnimationFrame(() => {
queued = false;
draw();
});
}, { passive: true });
</script>
The frames come from any clip with one ffmpeg command:
mkdir frames
ffmpeg -i clip.mp4 -vf "fps=24,scale=1280:-2" \
-q:v 4 frames/%04d.jpg
To stay short, the sample requests all 120 frames at once. A real page loads the first few first.
currentTimeKeep the clip as a video and set its playback position from the scroll. One file in place of 120 is the appeal.
<section class="scrub">
<video src="scrub.mp4" muted playsinline
preload="auto"></video>
</section>
<style>
.scrub { height: 400vh; }
.scrub video {
position: sticky; top: 0;
width: 100%; height: 100svh;
object-fit: cover;
}
</style>
<script>
const section = document.querySelector('.scrub');
const video = section.querySelector('video');
let target = 0, sought = -1;
// One seek at a time: never queue them up.
function seek() {
if (video.seeking || target === sought) return;
sought = target;
video.currentTime = target;
}
video.addEventListener('seeked', seek);
addEventListener('scroll', () => {
if (!video.duration) return; // not loaded yet
const r = section.getBoundingClientRect();
const p = -r.top / (r.height - innerHeight);
target = Math.min(1, Math.max(0, p)) * video.duration;
seek();
}, { passive: true });
</script>
Why it stutters. A normal web video stores a complete picture only at keyframes, often seconds apart, and describes the frames in between as changes. To show an arbitrary moment the browser must find the keyframe before it and decode forward. So setting currentTime starts a seek that finishes later (MDN: "Changing the value of currentTime seeks the media to the new time"), and scrolling asks for many per second. Three fixes:
-g 1 sets the group-of-pictures size to one. The file is larger than a normal encode of the same clip.seeked, then jumps straight to the latest target.ffmpeg -i clip.mp4 -an -c:v libx264 -g 1 -crf 20 \
-pix_fmt yuv420p -movflags +faststart scrub.mp4
The browser can tie an ordinary @keyframes animation to scroll position with no JavaScript. view-timeline gives the tall section a named timeline that follows its trip through the viewport, and animation-range: contain limits it to the stretch where the section fills the screen: exactly while the image is pinned.
<section class="stage">
<img src="product.png" alt="The product, front view">
</section>
<style>
.stage { height: 300vh; }
.stage img {
position: sticky; top: 0;
width: 100%; height: 100svh;
object-fit: contain;
}
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.stage { view-timeline: --stage; }
.stage img {
animation: turn linear both;
/* these two go after the shorthand */
animation-timeline: --stage;
animation-range: contain;
}
}
}
@keyframes turn {
from { opacity: .3;
transform: scale(.7) rotate(-12deg); }
to { opacity: 1; transform: none; }
}
</style>
The two anonymous forms cover the simpler cases: scroll() follows a scroll container, and view() follows an element's own trip through the viewport.
<div class="bar"></div>
<p class="card">Fades in on its way up the screen.</p>
<style>
.bar {
position: fixed; top: 0; left: 0;
width: 100%; height: 3px; background: #fff;
transform-origin: left;
animation: grow linear both;
animation-timeline: scroll(root); /* page scroll */
}
.card {
animation: rise linear both;
animation-timeline: view(); /* its own trip */
animation-range: entry 0% cover 40%;
}
@keyframes grow { from { transform: scaleX(0); } }
@keyframes rise {
from { opacity: 0; transform: translateY(2rem); }
}
</style>
Browser support, checked November 11, 2026. MDN's compatibility data lists animation-timeline, scroll() and view() from Chrome and Edge 115, Safari 26 and Firefox 159. Firefox 159 shipped on October 27, 2026 with scroll-driven animations on by default, and MDN's animation-timeline page now labels the feature "Baseline 2026, newly available". Newly available also means Safari before 26 and Firefox before 159 ignore it, so the sample writes the finished state as the default and adds the motion inside @supports. Reference: MDN: CSS scroll-driven animations.
The limit. CSS animates properties of things already on the page: position, scale, rotation, opacity, clipping. It cannot play footage.
| Route | Smoothness | File weight | Mobile behaviour | Effort |
|---|---|---|---|---|
| Image sequence on a canvas | Highest once frames are loaded: each scroll step is one image draw | Heaviest: one image per frame | Dependable if phones get smaller and fewer frames | Medium: export frames, write loading logic and canvas code |
Video withcurrentTime | Depends on the encode: stutters with sparse keyframes, good with a keyframe on every frame | One file. Small with a normal encode, larger with all keyframes | Least predictable: browsers are not obliged to seek instantly, and phones often do not | Little code, more encoding and device testing |
| CSS scroll-driven animations | High: declarative, so the browser can run it off the main thread | None beyond the element you animate | Chrome and Edge 115, Safari 26, Firefox 159 and later. Older browsers show the static end state | Lowest, but it cannot play footage |
| No-code (Scrollytelling) | The canvas image-sequence method, set up for you | Frames are extracted, optimised and served from a CDN | Works on phones | Upload a clip or generate one |
A working default: the image sequence for the product, CSS for the text around it, and video scrubbing only when you control the encode and have tested on real phones.
100svh, as the samples do, so a phone's toolbar does not clip it.prefers-reduced-motion. Visitors who turn on reduced motion should get the content without the scrubbing: unpin the section and show a still.preventDefault() on wheel or touch events and no eased scrolling of your own. The keyboard and the scrollbar keep working.The reduced-motion check for the canvas sample:
const calm = matchMedia(
'(prefers-reduced-motion: reduce)').matches;
if (calm) {
// no runway, no pin: show one still and stop
section.style.height = 'auto';
section.innerHTML =
'<img src="frames/0060.jpg" alt="The product">';
} else {
// load the frames and listen for scroll
}
The reasoning behind each rule is in scrollytelling design patterns. The code-level pitfalls (jank, layout thrashing, viewport units) are in the developer guide.
A search for 3D scrollytelling means one of two things, and the first costs more than the second by a wide margin.
Real-time 3D. A model is rendered live in the browser with WebGL, usually through three.js (a JavaScript 3D library), and the scroll position drives the camera or the model. The visitor's device renders every frame. Choose it when visitors must be free to look around, change a colour or open a part. The bill is asset production, a graphics budget that has to hold on a mid-range phone, a designed loading state and a fallback for devices that cannot run it.
Pre-rendered 3D. The scene is rendered once, in advance, into frames or a video, and the page scrubs them on scroll with route 1 or 2 above. The visitor's device only shows pictures, and the camera path is fixed. The AirPods Pro hero that CSS-Tricks rebuilt is this kind: 148 images and no WebGL.
The rule from our design patterns guide: if the camera path is authored, pre-render it. Live sites of both kinds are in our scrolling websites list: one product page paints pre-rendered frames on a 2D canvas, and one annual report draws its graphics live in WebGL. Scrollytelling.ai does the second route. It scrubs a clip, including a 3D render you upload, and it does not build real-time WebGL scenes or custom shaders.
Scrollytelling.ai builds the image-sequence version for you. You upload a clip, or let AI generate a hero clip, and the clip is rendered to a sequence of image frames and scrubbed on a canvas tied to scroll, which avoids the stutter of seeking a real <video>. The frames are extracted and optimised for you, served from a CDN and loaded progressively, so the first frames paint straight away and the rest arrive as the visitor scrolls. It works on phones.
One limit: because the extracted frames can run to dozens of megabytes, the scroll video is built and hosted inside Scrollytelling, not exported as a standalone snippet.
This is a live story built with Scrollytelling. Scroll inside the frame: the zoom from the edge of the universe follows your scroll, stops when you stop and reverses when you scroll back up.
Before making an account, open the free scroll video playground: try it with your own clip, no signup. Paste a link to a video, adjust the text and colours, and copy the embed code.
Plans as listed on our pricing section, checked November 11, 2026. Uploading your own clip and editing by hand do not use credits. Generating a clip or images with AI does.
| Plan | Price | Published stories | AI credits a month | Also includes |
|---|---|---|---|---|
| Free | $0 | 1, on a scrollytelling.ai address | 60 | All templates and widgets, built-in analytics, a small "made with" badge |
| Starter | $12 a month, or $10 a month billed yearly ($120) | 10 | 250 | Custom domain, badge removed |
| Pro | $29 a month, or $24 a month billed yearly ($290) | 50 | 1,000 | Form submissions, AI Spotlight interactive sections, custom code embed |
| Pro+ | $59 a month, or $49 a month billed yearly ($590) | Unlimited | 3,000 | Priority AI and support |
No demo call, no sales contact, and you cancel in the app. Full details: pricing. For agency and freelancer prices, see scrollytelling cost.
Shopify. The Scrollytelling app builds a scroll-driven page from a product, a collection or a campaign, including the scroll-scrubbed video, and publishes it to your storefront as a page, a blog article or a theme block. It does not edit your theme files, and product pages get add to cart built in. Building and previewing is free. Publishing starts on Starter at $12 a month, billed through Shopify. Details: scroll animation for Shopify.
WordPress. The official plugin embeds any published story in a post or page with a block, a shortcode, or by pasting the story link on a line of its own. The story runs in its own frame, isolated from your theme's CSS and JavaScript, and the plugin is free. Details: the WordPress plugin.
Apple has not published a how-to, so the answer comes from reading its pages. In May 2020 CSS-Tricks rebuilt the AirPods Pro hero as 148 images drawn to a canvas and chosen by scroll position. On November 11, 2026 the HTML of the AirPods Pro page held 15 video elements inside pinned scroll components and no canvas element. Both are the same idea: a pinned section whose visual is tied to the scroll position.
Yes. Set the video element's currentTime from the scroll position. A normal encode stutters, because the browser has to decode forward from the last keyframe on every seek. Encode every frame as a keyframe, run one seek at a time, and host the file where HTTP range requests work. For the smoothest result, draw an image sequence to a canvas.
Yes. Scrollytelling.ai turns an uploaded or AI-generated clip into a scroll-scrubbed section without code. The Free plan is $0 with 60 credits a month and one published story. Paid plans are $12, $29 and $59 a month, or $10, $24 and $49 a month billed yearly. Prices checked November 11, 2026.
A scroll-driven page whose visual is three-dimensional. Real-time 3D renders a model live in the browser with WebGL, usually through three.js, and the scroll moves the camera. Pre-rendered 3D renders the scene to frames or video in advance and scrubs them on scroll. The second is lighter, and it is how the AirPods Pro hero rebuilt by CSS-Tricks worked.
It can. An image sequence weighs its frame count multiplied by the size of one frame. Keep the clip short, size frames to the screen, show the first frame as a normal image, load the rest progressively and give phones fewer frames. CSS scroll-driven animations add almost no weight, but they cannot play footage.
Yes. Either a developer codes it into your theme, or an app builds it. The Scrollytelling app for Shopify builds a scroll-driven page from a product, collection or campaign and publishes it as a page, blog article or theme block without theme edits. Building is free, and publishing starts at $12 a month billed through Shopify.
Build one scroll-scrubbed page on the Free plan, or scrub a clip in the playground first. No code either way.
Create your story โ Open the scroll video playground โ