Building MP4 to Scroll Animation: A Fully Frontend Tool

by | Feb 20, 2026 | Articles, Projects | 0 comments

Tool live at luckytoolkit.com/mp4toscroll


Apple popularised the “scroll-jacked video” effect years ago on their product pages — video frames that advance as you scroll down, making the experience feel cinematic and interactive. It’s a powerful technique, but the usual implementation requires a backend pipeline, video encoding tools, and a fair amount of server infrastructure to host the frame data.

I wanted to see if the entire thing could run in a single HTML file, entirely in the browser — no uploads, no servers, no dependencies. Here’s how I built it.


What the Tool Does

You drop an MP4 (or any browser-supported video) onto the page. You configure how many frames per second to extract, the output quality, and the animation style. The tool extracts frames directly in the browser, lets you preview the result interactively, and then generates a self-contained output file you can download — a standalone HTML page where scrolling drives the animation.

The output also comes in four formats for developers: a full-page HTML file, an embed snippet you can paste into any existing page, a Web Component (<scroll-animation>), and a React component (.jsx).


Frame Extraction with Canvas

The browser has everything needed for this. A <video> element can load a local file via URL.createObjectURL(), and a <canvas> element can capture individual frames from it.

The key insight is that video.currentTime is seekable — you can jump to any timestamp and then wait for the seeked event before drawing to canvas:

function seekTo(video, time) {
  return new Promise(resolve => {
    const done = () => { video.removeEventListener('seeked', done); resolve(); };
    video.addEventListener('seeked', done);
    video.currentTime = time;
  });
}

Then extraction is a simple loop:

for (let i = 0; i < totalFrames; i++) {
  await seekTo(video, i / fps);
  ctx.drawImage(video, 0, 0, outputWidth, outputHeight);
  frames.push(canvas.toDataURL('image/jpeg', quality));
}

Each frame becomes a base64 JPEG data URL, embedded directly into the output. No file system access required. To keep the UI responsive during a potentially long extraction (30fps × 60 seconds = 1800 seeks), I yield back to the browser every 8 frames with await requestAnimationFrame().


The Scroll Animation Pattern

The core of the output animation is simple: map window.scrollY to a frame index, and draw that frame to a fixed canvas.

window.addEventListener('scroll', () => {
  const progress = window.scrollY / (document.body.scrollHeight - window.innerHeight);
  const frameIndex = Math.floor(progress * (frames.length - 1));
  ctx.drawImage(images[frameIndex], 0, 0, W, H);
});

For the Crossfade mode, instead of jumping between frames I alpha-blend adjacent frames based on the fractional position between them:

const ff = progress * (frames.length - 1);
const f1 = Math.floor(ff), f2 = f1 + 1;
const blend = ff - f1;

ctx.globalAlpha = 1;   ctx.drawImage(imgs[f1], 0, 0, W, H);
ctx.globalAlpha = blend; ctx.drawImage(imgs[f2], 0, 0, W, H);
ctx.globalAlpha = 1;

This makes the transition feel smooth even at low frame rates like 8–12fps, because the canvas is interpolating between adjacent frames on every pixel. It costs one extra drawImage call per frame but the result is noticeably better.


The Sticky-Container Embed Pattern

The standalone HTML takes over the full page scroll. But when a developer wants to embed the animation as a section within an existing page — not replace it — a different approach is needed.

The technique is called the sticky-container pattern:

<div id="container" style="height: 4000px; position: relative;">
  <div style="position: sticky; top: 0; height: 100vh;">
    <canvas></canvas>
  </div>
</div>

The outer div is tall — as tall as the total scroll distance for the animation. The inner div is position: sticky, so it stays pinned to the viewport while the user scrolls through the outer container. Scroll progress is measured not from window.scrollY but from the container’s position relative to the viewport:

const rect = container.getBoundingClientRect();
const total = container.offsetHeight - window.innerHeight;
const progress = Math.max(0, Math.min(-rect.top / total, 1));

When rect.top is 0, the container just entered the viewport — progress = 0. When rect.top equals -(container.height - viewport.height), we’ve scrolled to the end — progress = 1. Everything below and above the container scrolls normally. No scroll hijacking, no interference with the rest of the page.

This same pattern powers the Embed Snippet, Web Component, and React Component exports.


An HTML Parser Gotcha That Cost Me an Hour

The most interesting bug I hit was a silent script-termination caused by the HTML5 parser’s script data escape state.

When the browser’s HTML parser reads a <script> block, it scans for </script> to find where the block ends. The spec defines a special “Script data escaped state” that gets triggered when the parser sees <!-- inside a script element. In that state, </script> does end the block — even if it’s inside a JavaScript string.

The problem: the embed snippet generator opened its template literal with <!-- Scroll Animation — paste anywhere in <body> -->, which put the HTML parser into escaped state. A subsequent <\/script> tag (that I’d escaped thinking it was safe) was still being interpreted as end-of-script in some parsing paths, silently terminating the <script> block halfway through the file. Every function defined after that point — including doDownload() — was simply never parsed.

The fix is to break </script> using template literal interpolation, which the HTML parser never evaluates:

// Unsafe — HTML parser may terminate the script block
`<script>${content}<\/script>`

// Safe — interpolation is invisible to the HTML parser
`<script>${content}${'<'}/script>`

The ${'<'} evaluates at JavaScript runtime, not at HTML parse time, so the parser never sees the </ sequence. A small but sharp edge in the spec that’s easy to miss.


Export Formats

One of the goals was making the output actually usable in real projects, not just as a demo. Four formats:

Standalone HTML — zero setup, open it directly or <iframe> it anywhere.

Embed Snippet — a self-contained <div> + <script> block. Paste it into a WordPress post, a Webflow rich-text block, a Jekyll template, anywhere that accepts raw HTML. Uses the sticky-container pattern above.

Web Component — a .js file that defines <scroll-animation> as a Custom Element. One <script> tag and one custom element, nothing else needed. Supports a bg attribute for custom backgrounds. Works in Vue, Angular, Svelte, or plain HTML.

React Component — a .jsx file using useEffect and useRef. Fully self-contained (frames embedded as constants), cleans up the scroll listener on unmount, accepts className and style props.


Try It

The tool is free and runs entirely in your browser — nothing is uploaded anywhere.

luckytoolkit.com/mp4toscroll

Practical settings that work well: 8–12 fps, 70–80% JPEG quality, 1280px wide. That gives you smooth animation at a manageable file size — usually under 15MB for a 30-second clip. Anything over ~500 frames will produce a large HTML file, so for longer videos I recommend trimming to the section you actually need.

The source is a single HTML file with no dependencies. All the frame extraction, preview, and code generation happens client-side.


Built with a <canvas> element and too much curiosity about browser internals.

Written by

Related Posts

0 Comments

Submit a Comment

Your email address will not be published. Required fields are marked *