· Updated · Tutorials · By Simon Chiu

Website to GIF: Record a Scrolling Page with ffmpeg

Turn a website into a scrolling GIF with Playwright and an ffmpeg palette. File sizes measured for 13 settings, and when MP4 or WebP is the better choice.

Line illustration of the RenderScreenshot mascot holding a film strip of a scrolling web page

To turn a website into a GIF, capture the page as a sequence of screenshots while it scrolls, then encode those frames with ffmpeg using a palette built from the frames. That is two commands: a short Playwright script and one ffmpeg call. The settings you pick in the second command decide whether the GIF is 0.6 MB or 5.6 MB, so most of this post is measurements.

Animated GIF of a test landing page scrolling from a purple gradient hero down through feature cards, an orange photo-like gradient and a dark footer
The result: 480 px, 10 fps, 128-colour palette, 604,742 bytes. Produced with the scripts on this page.

This is a do-it-yourself pipeline. RenderScreenshot does not output GIF or video; its formats are PNG, JPEG, WebP, AVIF and PDF. If you need still captures of long pages, start with the full-page screenshot guide.

Tested on 2026-10-15 with Node 22.18.0, [email protected] (bundled Chromium), [email protected] and ffmpeg 9.0.1 (Homebrew), on an Apple M1 Pro with macOS 27.0.1. The test target is a local static landing page I wrote for this post: a gradient hero, white cards, a photo-like radial gradient and a dark footer, 2,784 px tall.

Why frames, not screen recording

There are two ways to get motion out of a headless browser:

  1. Step and shoot. Scroll to a position, wait for paint, take a screenshot, repeat. Every frame is complete, the timing is exact, and a slow machine produces the same output as a fast one. It is slower than real time: 90 frames at 1280 x 800 took 9.0 s on a warm run (14.6 s on the first run).
  2. Record a video. Playwright's recordVideo context option records the page in real time to WebM. In my run it produced a 1280 x 800, 25 fps VP9 file of 5.5 s (1,029,644 bytes). This is the right choice when the page itself animates (CSS transitions, carousels, video), because step-and-shoot would freeze or skip that motion.

For a scrolling page, step and shoot gives cleaner GIFs: no dropped frames, no half-painted tiles, and you control the easing.

Step 1: capture the frames

The script scrolls with an ease-in-out curve, holds the first and last frame for one second, and turns off smooth scrolling and CSS animations so every frame is deterministic.

// frames.mjs - scroll a page and save one PNG per frame.
// Node 22+, [email protected]. Usage: node frames.mjs <url> <outDir> [seconds=4] [fps=15]
import { mkdir, rm } from 'node:fs/promises';
import { chromium } from 'playwright';

const [url, dir = 'frames', seconds = '4', fps = '15'] = process.argv.slice(2);
const FRAMES = Number(seconds) * Number(fps);
const HOLD = Number(fps); // hold the first and last frame for one second each

await rm(dir, { recursive: true, force: true });
await mkdir(dir, { recursive: true });

const browser = await chromium.launch();
try {
  const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
  await page.goto(url, { waitUntil: 'networkidle' });
  // No smooth scrolling, no caret, no CSS animations: every frame is deterministic.
  await page.addStyleTag({ content: 'html { scroll-behavior: auto !important; } *, *::before, *::after { animation-play-state: paused !important; caret-color: transparent !important; }' });

  const max = await page.evaluate(() => document.scrollingElement.scrollHeight - innerHeight);
  let n = 0;
  const shot = () => page.screenshot({ path: `${dir}/${String(n++).padStart(4, '0')}.png` });

  for (let i = 0; i < HOLD; i++) await shot();
  for (let i = 1; i <= FRAMES; i++) {
    const t = i / FRAMES;
    const eased = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2; // ease in-out
    await page.evaluate((y) => scrollTo(0, y), Math.round(eased * max));
    await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
    await shot();
  }
  for (let i = 0; i < HOLD; i++) await shot();
  console.log(JSON.stringify({ frames: n, scrolled: max }));
} finally {
  await browser.close();
}
npm i [email protected]
npx playwright install chromium
node frames.mjs http://localhost:3000/landing.html frames 4 15
# {"frames":90,"scrolled":1984}

The 90 PNG frames used 14 MB on disk. If the page loads more content as you scroll, load it before the first frame or the scroll distance changes mid-run; our post on infinite scroll pages has a bounded loader you can call first.

Step 2: encode with a palette

A GIF frame can use at most 256 colours. If you do not give ffmpeg a palette, it uses a generic one and dithers heavily to fake the missing colours. The fix is the two-filter pattern: palettegen scans your frames and picks the 256 (or fewer) colours they actually use, and paletteuse maps each frame to that palette.

ffmpeg -framerate 15 -i frames/%04d.png \
  -vf "fps=10,scale=480:-1:flags=lanczos,split[a][b];[a]palettegen=max_colors=128[p];[b][p]paletteuse" \
  landing-scroll-480.gif

Read the filter chain left to right: drop from 15 to 10 fps, scale to 480 px wide with the Lanczos filter, then split the stream so one copy builds the palette and the other is mapped through it. split lets you do both passes in one command instead of writing a palette PNG to disk first.

Measured: file size by setting

All rows encode the same 90 frames (6 seconds: 1 s hold, 4 s scroll, 1 s hold). "Default dither" is ffmpeg's sierra2_4a.

# Output Width fps Palette Dither Bytes
01 GIF 640 15 ffmpeg default (no palettegen) default 4,283,650
02 GIF 1280 15 palettegen default 5,640,399
03 GIF 640 15 palettegen default 1,553,009
04 GIF 640 15 palettegen stats_mode=diff bayer, diff_mode=rectangle 2,079,923
05 GIF 640 10 palettegen stats_mode=diff bayer, diff_mode=rectangle 1,408,412
10 GIF 640 10 palettegen default 1,032,839
11 GIF 640 10 palettegen bayer (scale 3) 1,365,539
07 GIF 640 10 palettegen stats_mode=diff none 930,704
06 GIF 480 10 palettegen 128 colours, stats_mode=diff bayer 595,327
12 GIF 480 10 palettegen 128 colours default 604,742
13 Animated WebP (sharp, quality 70) 640 10 n/a n/a 183,894
08 MP4, H.264 CRF 26 640 15 n/a n/a 83,875
09 MP4, H.264 CRF 26 1280 15 n/a n/a 259,638

What I take from the table:

  • The palette is the biggest single win. Same width and fps, generic palette vs palettegen: 4.28 MB vs 1.55 MB (rows 01 and 03).
  • Width and frame rate multiply. 1280 px to 640 px and 15 to 10 fps took the palettegen GIF from 5.6 MB to 1.0 MB (rows 02 and 10).
  • Advice you will read elsewhere did not help here. stats_mode=diff with diff_mode=rectangle is designed for clips where only a small area changes between frames. In a scroll, every pixel moves on every frame, so it made the file larger (row 04 vs 03). Bayer dithering was also larger than the default for this content (row 11 vs 10).
  • Any video format beats GIF by an order of magnitude. The 640 px MP4 is 11 times smaller than the smallest 640 px GIF (row 08 vs 07) and shows the full colour range.

What the dither setting looks like

Four frames of the same moment in the scroll: the generic-palette GIF has a speckled background and a noisy gradient, sierra and bayer dithering look smooth, and no dithering shows visible rings in the gradient
The same frame from GIFs 01, 10, 11 and 07. Note the tinted, speckled white background in the generic-palette GIF (top left).
Three-times zoom of the orange gradient: generic palette with coarse noise, palettegen with sierra dithering that is smooth with a few stray white pixels, and no dithering with clear concentric bands
3x zoom of the gradient. Left: generic palette. Middle: palettegen + sierra2_4a (smooth, with a few stray white pixels near the highlight). Right: no dither (banding).

Pick by content. Flat UI (text, solid buttons, white cards) looks fine with dither=none, which is also the smallest GIF at a given size. Photos and gradients need dithering, or they band. If the page is mostly photos, GIF is the wrong format; use MP4 or WebP.

Delivering something smaller than a GIF

If the place you post to accepts video, send video. For a web page, a muted looping video behaves like a GIF:

<video src="landing-scroll-640.mp4" autoplay muted loop playsinline width="640" height="400"></video>
The same scroll as H.264 MP4, 640 px, 83,875 bytes (row 08).
ffmpeg -framerate 15 -i frames/%04d.png \
  -vf "scale=640:-2:flags=lanczos,format=yuv420p" \
  -c:v libx264 -crf 26 -preset slow -movflags +faststart landing-scroll-640.mp4

format=yuv420p makes the file play in every browser and phone, -2 keeps the height even (H.264 needs it), and +faststart moves the index to the front so playback starts before the download ends.

When a GIF is required but the host also accepts animated WebP, sharp can build one from the same frames. My ffmpeg build had no libwebp encoder, which is common in package-manager builds, so this avoids a rebuild:

// webp.mjs - animated WebP from the same frames. [email protected]
import { readdir } from 'node:fs/promises';
import sharp from 'sharp';
const dir = process.argv[2] ?? 'frames';
const files = (await readdir(dir)).filter((f) => f.endsWith('.png')).sort()
  .filter((_, i) => i % 3 !== 2) // 15 fps -> 10 fps: keep 2 of every 3 frames
  .map((f) => `${dir}/${f}`);
const frames = await Promise.all(files.map((f) => sharp(f).resize(640).toBuffer()));
await sharp(frames, { join: { animated: true } })
  .webp({ quality: 70, delay: Array(frames.length).fill(100), loop: 0 })
  .toFile(process.argv[3] ?? 'landing-scroll-640.webp');
console.log(frames.length, 'frames');

The encoder merged identical hold frames, so the 60 input frames became 42 stored frames.

Recording real animations instead

For pages that move on their own, record video and convert it. Playwright writes the file when the context closes:

// record.mjs - record real-time video of a smooth scroll with Playwright, then convert with ffmpeg.
// Node 22+, [email protected].
import { chromium } from 'playwright';

const [url, dir = 'video'] = process.argv.slice(2);
const browser = await chromium.launch();
try {
  const context = await browser.newContext({
    viewport: { width: 1280, height: 800 },
    recordVideo: { dir, size: { width: 1280, height: 800 } },
  });
  const page = await context.newPage();
  await page.goto(url, { waitUntil: 'networkidle' });
  await page.evaluate(async () => {
    const max = document.scrollingElement.scrollHeight - innerHeight;
    const start = performance.now();
    await new Promise((done) => {
      const step = (now) => {
        const t = Math.min(1, (now - start) / 4000);
        scrollTo(0, max * t);
        t < 1 ? requestAnimationFrame(step) : done();
      };
      requestAnimationFrame(step);
    });
  });
  await new Promise((r) => setTimeout(r, 500)); // hold the last frame for half a second
  const video = page.video();
  await context.close(); // the video file is written on close
  console.log(await video.path());
} finally {
  await browser.close();
}
ffmpeg -i video/page@*.webm \
  -vf "fps=10,scale=480:-1:flags=lanczos,split[a][b];[a]palettegen=max_colors=128[p];[b][p]paletteuse" \
  recorded.gif

The recorded clip includes the page load at the start (the video begins when the page opens), so trim it with -ss 0.8 or similar before -i. Converted with the same 480 px settings, it was 816,982 bytes, larger than the step-and-shoot GIF because it is longer and has 25 source frames per second to sample from.

Troubleshooting

Symptom Cause Fix
Speckled or tinted background No palettegen; ffmpeg used a generic palette Use the split, palettegen, paletteuse chain
Rings or bands in gradients and photos dither=none Use the default dither, or deliver MP4 or WebP
GIF plays slightly slow GIF delays are stored in hundredths of a second; 15 fps becomes 7/100 s per frame (14.3 fps) Use 10, 20, 25 or 50 fps, which divide evenly
Jumps or torn frames Screenshot taken before the scroll was painted Wait two requestAnimationFrame callbacks after each scroll
Scroll distance changes during the clip Lazy content loaded mid-run Scroll to the bottom and back before the first frame
Sticky header flickers A header that changes style on scroll Pin its style with addStyleTag, or hide it
Unknown encoder 'libwebp' ffmpeg built without libwebp Use the sharp script above
MP4 does not play on a phone Pixel format is not yuv420p Add format=yuv420p to the filter chain
width not divisible by 2 Odd dimension with H.264 Scale with -2 for the computed side

The scripts used for this post are frames.mjs, record.mjs and encode.sh, which produces rows 01 to 09 of the size table. For still images of the same page, see screenshots in Playwright; for choosing between still formats, see the PNG vs JPEG vs WebP comparison.

Related in this topic