# Record a product demo with Playwright

> Record a product demo video with Playwright instead of screen recording. Script clicks, ffmpeg crop and encode, autoplay video for mobile fallbacks.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-07-25 | Topics: [Node.js](https://flaviocopes.com/tags/node/) | Canonical: https://flaviocopes.com/record-demo-video-playwright/

Screen recordings drift. You re-record every time the UI changes. You miss the same click twice.

For [StackPlan](https://stackplan.dev) I wanted a homepage demo that always looked identical. Playwright drove the browser. ffmpeg trimmed and encoded the result.

## Why not record by hand

The hero has an interactive engineering dial over a stack canvas. It needs width. On phones the live canvas is hidden — too cramped to use.

So small screens get a **video** instead. Same story, no interaction required. That video had to stay in sync with the real UI.

A script beats QuickTime every time the dial labels or prices change.

## Record with Playwright

Launch Chromium with video capture enabled:

```js
import { chromium } from 'playwright'

const browser = await chromium.launch()
const context = await browser.newContext({
  recordVideo: {
    dir: './recordings',
    size: { width: 1280, height: 720 },
  },
  viewport: { width: 1280, height: 720 },
})
const page = await context.newPage()

await page.goto('http://localhost:4321')
await page.waitForSelector('.dial-range')

// Drive the demo — same steps every run
await page.click('button:text("Overkill")')
await page.waitForTimeout(800)
await page.click('button:text("Duct tape")')
await page.waitForTimeout(800)
for (let level = 0; level <= 4; level++) {
  await page.locator('.dial-range').fill(String(level))
  await page.waitForTimeout(600)
}

await context.close()
await browser.close()
```

Playwright writes a `.webm` into `recordings/`. Close the context before you post-process — the file finalizes on close.

Use explicit waits on animations, not blind sleeps, when you can. A few hundred milliseconds per dial step matches the CSS transition time.

Point Playwright at your local dev server or a preview deploy. The URL doesn't matter as long as the demo data is stable. StackPlan uses a canned stack in the hero so prices don't change between runs.

## Crop and encode with ffmpeg

The recording includes the whole viewport. Crop to the demo card's bounding box if you measured it once:

```bash
ffmpeg -i recordings/demo.webm \
  -vf "crop=760:520:260:180" \
  -c:v libx264 -pix_fmt yuv420p public/demo.mp4

ffmpeg -i recordings/demo.webm \
  -vf "crop=760:520:260:180" \
  -c:v libvpx-vp9 -b:v 0 -crf 35 public/demo.webm

ffmpeg -i public/demo.mp4 -frames:v 1 public/demo-poster.jpg
```

Serve **mp4** for Safari, **webm** for Chrome, and a **poster** frame so layout doesn't jump before playback.

## Embed on mobile

The interactive canvas stays on `sm:` and up. Below that breakpoint, show the recording:

```html
<div class="hidden sm:block">
  <!-- live Alpine canvas -->
</div>

<div class="sm:hidden">
  <video
    src="/demo.mp4"
    poster="/demo-poster.jpg"
    autoplay
    muted
    loop
    playsinline
    class="w-full"
  ></video>
</div>
```

`autoplay` only works muted. `playsinline` stops iOS from forcing fullscreen. `loop` keeps the hero alive without a replay button.

## Keep the script throwaway

The recording script doesn't belong in your app bundle. It's a dev tool you run before deploy when the demo changes.

Check the mp4, webm, and poster into `public/` or upload to R2. The site just serves static files. Keep the Playwright script in a `scripts/` folder with a one-line npm script:

```json
"record:demo": "node scripts/record-demo.mjs"
```

Next time the dial animation changes, run `npm run record:demo` again. Same clicks, same timing, new pixels. No third take at midnight.
