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.
Hand-recorded demos drift when the UI changes, and it’s easy to 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, so the live canvas is hidden on phones where it would be too cramped to use.
So small screens get a video instead. It shows the same story without requiring interaction, and it has to stay in sync with the real UI.
I can rerun the script whenever 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 post-processing because the file finalizes on close.
The Playwright docs tell you not to use page.waitForTimeout() in tests, because tests that sleep are flaky. It’s not deprecated though, and this is not a test. Here the pauses are the point: they give the CSS transition time to play out on camera. A few hundred milliseconds per dial step matches the transition time. If you can wait for a real UI state instead (a class change, an element appearing), do that.
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.
If you want a wider intro to Playwright beyond recording, see Playwright for end-to-end testing.
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 and webm for Chrome. Add a poster frame so the 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. The script repeats the same clicks and timing without another manual take.
Want me to talk about your product? You can sponsor this site.
Related posts about node: