| name | capture-demo-gif |
| description | Capture an animated demo GIF of a web application using Playwright screenshots and ffmpeg. Use when creating a demo GIF for a README, when the user asks to record or capture a web app demo, or when a project needs a visual demo for documentation. |
Capture Demo GIF for a Web App
Record a scripted walkthrough of a web application as an animated GIF for README embedding. Uses headless Playwright for screenshots and ffmpeg for GIF encoding.
Prerequisites
- Node.js 20+
- ffmpeg installed (
brew install ffmpeg)
- Dev server running for the target app
Workflow
1. Plan the Demo Scenes
Before writing code, decide which interactions to show. A good demo GIF has 8-15 frames covering:
- Initial state (app loaded)
- 2-3 key interactions (click, type, navigate)
- A result/output state
- Total duration: 15-25 seconds
2. Write the Capture Script
Create a file at <project>/demo/capture.mjs:
import { chromium } from 'playwright';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { mkdirSync, rmSync, statSync } from 'fs';
import { execSync } from 'child_process';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = join(__dirname, '..');
const FRAMES_DIR = join(PROJECT_ROOT, 'demo', '.frames');
const OUTPUT = join(PROJECT_ROOT, 'assets', 'demo.gif');
const fps = 0.65;
const gifWidth = 960;
const WIDTH = 1280;
const HEIGHT = 720;
const BASE = process.env.DEMO_URL || 'http://localhost:5173/';
let frameNum = 0;
async function snap(page, label, ms = 600) {
await page.waitForTimeout(ms);
frameNum++;
const f = `frame_${String(frameNum).padStart(2, '0')}.png`;
await page.screenshot({ path: join(FRAMES_DIR, f), type: 'png' });
console.log(` [${f}] ${label}`);
}
async function main() {
mkdirSync(FRAMES_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true });
const page = await (await browser.newContext({
viewport: { width: WIDTH, height: HEIGHT },
deviceScaleFactor: 2,
})).newPage();
await page.goto(BASE, { waitUntil: 'networkidle' });
await page.waitForTimeout(1500);
await snap(page, 'Initial state', 300);
console.log(`\n${frameNum} frames captured`);
await browser.close();
const filter = [
`scale=${gifWidth}:-1:flags=lanczos`,
'split[s0][s1]',
'[s0]palettegen=max_colors=128:stats_mode=diff[p]',
'[s1][p]paletteuse=dither=bayer:bayer_scale=3',
].join(',');
execSync(
`ffmpeg -y -framerate ${fps} -i "${join(FRAMES_DIR, 'frame_%02d.png')}" -vf "${filter}" "${OUTPUT}"`,
{ stdio: 'inherit' },
);
const size = (statSync(OUTPUT).size / 1024).toFixed(0);
console.log(`\n${OUTPUT} (${size} KB)`);
rmSync(FRAMES_DIR, { recursive: true, force: true });
}
main().catch(err => { console.error(err); process.exit(1); });
3. Key Settings
| Setting | Default | Notes |
|---|
deviceScaleFactor | 2 | Retina-quality screenshots. Use 1 for smaller files |
fps | 0.65 | Frames per second in GIF. Lower = longer per frame. 0.5 = 2s/frame, 1 = 1s/frame |
gifWidth | 960 | Output GIF width. Source is 1280ร720 at 2ร DPR |
max_colors | 128 | GIF palette size. Lower = smaller file, worse gradients |
| Viewport | 1280ร720 | Standard 720p. Don't go smaller or UI elements get cramped |
4. Common Playwright Actions for Demos
await page.locator('button:has-text("Save")').click();
await page.locator('.item-list > div').nth(2).click();
const input = page.locator('input[placeholder="Search"]');
await input.fill('hello world');
const panel = page.locator('.scrollable-area');
await panel.evaluate(el => el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }));
await page.waitForTimeout(800);
await page.locator('button[title="Dark mode"]').click();
5. Install, Run, Embed
cd <project>
npm install --no-save playwright
node demo/capture.mjs
Add to .gitignore:
demo/.frames
Embed in README:

6. Troubleshooting
GIF too large (>2MB)?
- Reduce
gifWidth to 640
- Increase
fps (fewer seconds = fewer unique frames needed)
- Reduce
max_colors to 64
- Remove frames that don't add value
Screenshots blank/wrong?
- Increase
waitForTimeout before first snap() -- app may not be fully rendered
- Check if the dev server URL is correct (some apps use a base path)
- Try
headless: false to see what Playwright sees
Selectors not matching?
- Use
page.locator('text=Visible Text') for the most robust selectors
- Use
await page.pause() with headless: false to inspect the DOM
- Avoid CSS class selectors that include Tailwind hashes
When NOT to Use This
- Terminal/CLI tools โ Use the
render-remotion-animation skill with the CliDemo template instead
- Library code demos โ Use
render-remotion-animation with CodeBeforeAfter template
- Interactive web apps with drag-drop, live preview, UI flows โ Use this skill