Record a product demo with Playwright
By Flavio Copes
Record a product demo video with Playwright instead of screen recording. Script clicks, ffmpeg crop and encode, autoplay video for mobile fallbacks.
Screen recordings drift. You re-record every time the UI changes. You miss the same click twice.
For StackPlan 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:
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:
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:
<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:
"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.
Related posts about node: