SAVE THIS

That Apple Scroll Animation Is Not a Video

The smooth product-page animation on Apple's site is a sequence of still images drawn to a canvas, not a video. Here is the full pipeline, the eight rules that keep it smooth, and the prompts to generate your frames.

You have seen it. You scroll down an Apple product page and the AirPods rotate, the MacBook opens, the phone spins to show its back. It feels like a film you control with the scroll wheel. Most people assume there is a video behind it. There is not.

It is a stack of still images. As you scroll, the code picks the right frame and paints it to a canvas. That is the whole trick. Video would flicker, blank out, and refuse to scrub cleanly. A frame sequence does not.

This is the technical guide to building that effect yourself. The pipeline, the eight rules that keep it smooth on a real machine, the copy-this-not-that table, and a prompt library for generating the frames with AI. If you build websites for clients, this is the difference between a page that looks five years old and one that looks like the product pages the big brands ship.

The Pipeline Is Four Steps

Apple uses this on the AirPods, MacBook, and iPhone pages. Samsung uses it. So do most of the sites that win design awards. The concept is short:

Copy this.

Video (MP4) → FFMPEG → Image Sequence (WebP) → Canvas + Scroll Logic

You start with a rendered video. You extract it into individual frames. You preload every frame into memory. Then you draw the correct frame to a canvas based on scroll position. The concept fits on one line. The reason most attempts stutter is the implementation. That is what the eight rules below fix.

Rule 1: Use FFMPEG to Extract Frames

Start with your rendered video. It can come from Google Flow, a 3D render, or a screen recording. Extract it into individual frames with FFMPEG:

Copy this.

ffmpeg -i animation.mp4 -vf "fps=30" frames/frame-%04d.webp

The sweet spot is 120 to 200 frames for most scroll sections. That is smooth enough to read as motion without bloating the page. If your count runs high, lower the FPS or trim the video before you extract. The %04d gives you zero-padded filenames, so frame-0001.webp sorts before frame-0002.webp and nothing plays out of order.

The mistake that makes it fail: dumping 500 frames because more sounds smoother. It is not smoother. It is a heavier page and a longer preload for motion the eye cannot resolve. Thirty frames goes the other way and looks choppy. Stay in the 120 to 200 band.

Rule 2: Use WebP, Not JPG

WebP is the right format for these frames. It runs 25 to 35 percent smaller than JPEG at the same visual quality, every modern browser supports it, and it keeps transparency if a frame needs it.

Real numbers from a real build: 160 frames at 1928 by 1076 came to roughly 19MB, about 119KB per frame. The same frames as JPG land closer to 25 to 30MB. On a scroll animation that every visitor preloads before they see anything, that gap is the difference between a fast page and a slow one.

Control the quality on export:

Copy this.

ffmpeg -i animation.mp4 -vf "fps=30" -quality 80 frames/frame-%04d.webp

The mistake that makes it fail: shipping PNG for photographic frames. PNG is lossless and enormous for this kind of content. Reserve it for flat graphics, not product renders.

Rule 3: Render on Canvas, Not a Video Element

Do not use a <video> element for scroll-driven animation. Video cannot be scrubbed frame-accurately. video.currentTime is unreliable, browsers decode asynchronously, and you get flickering or blank frames the moment the user scrolls fast. This is the single decision most people get wrong.

Use a <canvas> instead. Drawing a frame to canvas is a GPU blit, which is close to instant:

Copy this.

const canvas = document.getElementById('frame-canvas');
const ctx = canvas.getContext('2d');

// Pixel-perfect, instant frame switching
ctx.drawImage(frames[currentFrame], 0, 0, width, height);

That is the entire render call. One line does the work. Everything else in this guide exists to feed it the right frame at the right moment.

Rule 4: Preload All Frames Before the Experience Starts

Load every frame upfront behind a loading screen. If frames arrive mid-scroll, the user sees blank flashes and jumps. But do not fire all 160 requests at once. Browsers cap concurrent connections at around 6 per domain, so 160 parallel requests would actually run slower, not faster. Batch them:

Copy this.

const TOTAL = 160;
const BATCH = 20;

for (let i = 0; i < TOTAL; i += BATCH) {
  const batch = [];
  for (let j = i; j < Math.min(i + BATCH, TOTAL); j++) {
    batch.push(loadFrame(j));
  }
  await Promise.all(batch);
  updateLoadingProgress(i + BATCH, TOTAL);
}

Batches of 15 to 20 keep the pipeline full without choking the browser. Show a percentage or a progress bar so the user knows something is happening. Only remove the loading overlay once every frame is ready.

The mistake that makes it fail: lazy-loading frames as the user scrolls. It saves nothing on a page where every frame gets used anyway, and it guarantees a blank flash the first time someone scrolls faster than the network.

Rule 5: Use position: sticky With a Tall Scroll Container

This is the structural trick that makes the whole thing work. A tall container creates the scroll distance. A sticky wrapper pins the canvas in place while the user scrolls through that distance.

Copy this.

.scroll-container {
  height: 400vh; /* the scroll runway: how far the user scrolls */
}

.sticky-wrapper {
  position: sticky;
  top: 0;
  height: 100vh; /* pins the canvas in the viewport */
  overflow: hidden;
}

The 400vh container gives you three viewport-heights of scroll distance to map your frames across. The sticky wrapper keeps the canvas fixed on screen while that scrolling happens. Your scroll progress is a simple ratio:

Copy this.

progress = -rect.top / (rect.height - window.innerHeight)

Taller container means slower animation. Shorter means faster. That one number is your pacing dial.

Rule 6: Separate Scroll Calculation From Rendering

This is the rule that decides whether the page feels smooth or janky. Never draw to the canvas inside the scroll handler. Scroll events fire hundreds of times per second on a trackpad. If each one triggers a draw, you drown the main thread.

Split it into two systems. The scroll handler only calculates which frame to show. A separate requestAnimationFrame loop does the drawing, and only when the frame actually changed:

Copy this.

let currentFrame = 0;
let drawnFrame = -1;

// Scroll handler: ONLY calculates which frame to show
window.addEventListener('scroll', () => {
  const progress = getScrollProgress();
  currentFrame = Math.min(
    Math.floor(progress * TOTAL), TOTAL - 1
  );
}, { passive: true });

// rAF loop: ONLY draws when the frame actually changed
function tick() {
  if (currentFrame !== drawnFrame) {
    ctx.drawImage(frames[currentFrame], 0, 0, FW, FH);
    drawnFrame = currentFrame;
  }
  requestAnimationFrame(tick);
}
tick();

The { passive: true } flag tells the browser the listener will never block scrolling, so scrolling stays smooth. The drawnFrame !== currentFrame check means you never redraw the same frame twice. And requestAnimationFrame throttles the drawing to the display refresh rate on its own. Three small choices, and the jank is gone.

Rule 7: Tie Content Overlays to Scroll Position, Not Timers

Most of these animations have cards, labels, or callouts that appear at certain moments. Do not drive them with setTimeout or CSS keyframe timers. Map them to scroll ranges instead, so the user controls the pacing:

Copy this.

const phases = [
  { el: document.getElementById('phase-1'), start: 0.08, end: 0.24 },
  { el: document.getElementById('phase-2'), start: 0.28, end: 0.46 },
  { el: document.getElementById('phase-3'), start: 0.50, end: 0.68 },
  { el: document.getElementById('phase-4'), start: 0.72, end: 0.92 },
];

// Inside scroll handler:
for (const phase of phases) {
  if (progress >= phase.start && progress <= phase.end) {
    phase.el.classList.add('visible');
  } else {
    phase.el.classList.remove('visible');
  }
}

Content tied to scroll position always feels responsive, no matter how fast or slow someone scrolls. Use CSS transitions for the fade so it stays smooth. Leave small gaps between ranges, like 0.24 to 0.28, so two cards never overlap.

The mistake that makes it fail: a timer. Scroll up and the timer keeps running forward. The card fires at the wrong moment and the illusion breaks.

Rule 8: Polish the Details That Elevate It

The animation works after seven rules. These are the touches that make it look expensive.

Soft-edge the canvas with a radial gradient mask so the frames float into the background instead of sitting in a hard box:

Copy this.

#frame-canvas {
  mask-image: radial-gradient(
    ellipse 65% 60% at 52% 50%, black 40%, transparent 75%
  );
}

Add a subtle rotation tied to scroll for a sense of depth, even with flat 2D frames:

Copy this.

const rotation = -4 + progress * 12; // sweeps -4deg to +8deg
canvas.style.transform = `translate(-50%, -50%) rotate(${rotation}deg)`;

Two more that cost almost nothing. A thin accent-colored progress bar at the top of the viewport shows overall progress. And glassmorphic overlay cards with backdrop-filter: blur() let the animation show through the content, which reads as premium.

Copy This, Not That

If you remember one thing from this guide, make it this table. Every row is a decision that either holds the animation together or breaks it.

DecisionDo ThisNot This
Image formatWebPJPG or PNG
Playback element<canvas><video>
Frame loadingBatched preload, all upfrontLazy load on scroll
Scroll handling{ passive: true }, set state onlyDraw inside scroll handler
RenderingSeparate rAF loopDraw in scroll event
Layoutposition: sticky plus tall containerJS-driven positioning
Content timingScroll-position rangessetTimeout or CSS animation
Frame count120 to 200 frames500 plus (heavy) or 30 (choppy)

The Frame Library: Generate Both Ends With AI

You do not need a 3D artist to get frames. You need a good start frame and a good end frame, and an AI image generator to interpolate the motion between them. The start frame is the product assembled. The end frame is the same product exploded into its layers. The one thing that makes or breaks it is visual consistency: same angle, same lighting, same background in both frames. Change any of those and the animation looks like two different photos, not one object moving.

These prompts work with Google Whisk, Midjourney, or any generator. Copy them, swap the product, keep the structure.

Copy the Start Frame Prompt (Assembled)

Copy this.

High-end product photograph of a premium 75% mechanical
keyboard viewed from a 3/4 top-down angle, fully assembled
and pristine. Machined aluminum case with matte black
anodized finish and subtle chamfered edges that catch the
light. Double-shot PBT keycaps in white-on-dark colorway
with crisp legends. Visible rotary encoder knob in the top
right corner with knurled metal texture. Coiled aviator
USB-C cable extends from the back in matching black.
Centered on clean white background with soft diffused
contact shadows. Studio lighting with soft key light from
upper left, gentle rim lighting along right edge. Shot as
if on 70mm macro lens with shallow depth of field softening
the far keys slightly. Ultra-detailed, photorealistic.

Copy the End Frame Prompt (Exploded)

Upload the start frame first, then add this description so the generator keeps the same object and angle:

Copy this.

Same premium 75% mechanical keyboard from identical 3/4
top-down angle, now as a fully exploded view with every
layer separated and floating in space with even vertical
spacing. From top to bottom: white PBT keycaps as complete
grid at top, below them individual mechanical switches
showing cross-stem housings, below that brushed steel
switch plate, below that PCB circuit board with visible
RGB LEDs and microcontroller, below that silicone dampening
layer, at bottom the aluminum case shell with hollow
interior visible. Coiled aviator cable floats detached
near the case. Each layer casts soft shadow on layer below.
Same studio lighting, soft key light upper left, rim
lighting on right. Clean white background. Same 70mm macro
lens, shallow depth of field. Ultra-detailed, photorealistic.

Adapt Them for Any Product

The keyboard is the example. The recipe is general:

  1. Start frame: describe your product fully assembled. Materials, finishes, colors, camera angle, lighting, background. Be specific about all five.
  2. End frame: upload the start frame, then describe every internal layer separated and floating. Hold the exact same angle, lighting, and background.
  3. Consistency keywords: put "same angle", "same lighting", "identical perspective", and "clean white background" in both prompts. These are the words that keep the two frames on the same object.

Once you have the two ends, generate the frames between them, run the sequence through Rule 1, and you have your MP4 input for the pipeline.

Honest: When Not to Build This

This effect is not free, and it is not always right.

It costs weight. Every visitor preloads the full frame sequence before they see the animation. At roughly 19MB for 160 frames, that is a real download on a phone on cellular data. Budget for it, and never put one of these above the fold on a page where speed decides the sale.

It needs a fallback. On a slow connection or a device that respects reduced-motion settings, you owe the user a static image instead of a spinner they stare at. Check for prefers-reduced-motion and serve one clean frame when it is set.

And it is a hero moment, not a layout tool. One scroll animation on a landing page is memorable. Three on the same page is a slow, heavy site that annoys people. Use it where the product is the story, and nowhere else.

If you want the smoother, timeline-driven version of this with a library handling the scroll math for you, the GSAP approach covers that path. The canvas pipeline here is what you reach for when you want full control and no dependency.

If You Build One This Week

Do not start with your homepage. Take one product, generate a start frame and an end frame with the prompts above, and run Rule 1 to get your sequence. Wire up rules 3, 4, 5, and 6, which are the four that actually make it move. Skip the polish in Rule 8 until it works.

You will have a scrubbing, canvas-drawn, Apple-style animation running on a local page in an afternoon. If it stutters, the cause is almost always Rule 6: you are drawing inside the scroll handler. Move the draw into the requestAnimationFrame loop and it smooths out. Done. You now build the effect agencies charge thousands for.

This guide is one system.
The map tells you which comes first.

The guides show you the systems. The map shows you which one your business needs first.

Get your free map →
Take this with you Grab the file version → Download as PDF ↓

Prefer to browse with company? The free community has the full skill library.