Playwright Recording

digitalsamba/claude-code-video-toolkit/.claude/skills/playwright-recording

by digitalsamba2c99460a5d0cba253f983283fb8d0fb9c4edb4a7No license2.1K starsListed Oct 9, 2026Updated Oct 9, 2026Repository updated 3 days ago

Record browser interactions as video using Playwright. Use for capturing demo videos, app walkthroughs, and UI flows for Remotion videos. Triggers include recording a demo, capturing browser video, screen recording a website, or creating walkthrough footage.

AI-generated overview

Guides recording browser interactions as video with Playwright for demo and walkthrough footage.

What it does
This skill provides instructions and TypeScript code patterns for using Playwright to record browser sessions as video. It covers installation, viewport and quality settings, recording patterns such as form submission, multi-page navigation, scrolling and login flows, cursor and click-ripple visualizations, cookie banner dismissal, and exporting recordings for use in Remotion compositions. It also describes converting WebM output to MP4 with ffmpeg and handling interactive, user-driven recordings.
When to use it
Use it when you need to capture demo videos, app walkthroughs, or UI flow footage, especially for Remotion video projects. It fits tasks such as recording a website demo, capturing browser video, or producing walkthrough footage.
Requirements
Requires Node.js with Playwright and @playwright/test installed, plus the Chromium browser via npx playwright install chromium. Running the example scripts needs ts-node or tsx, and MP4 conversion needs ffmpeg. It ships no scripts, only instructions and a reference document.

Playwright Video Recording

Playwright can record browser interactions as video - perfect for demo footage in Remotion compositions.

Quick Start

Installation

bash
# In your video projectnpm init -ynpm install -D playwright @playwright/testnpx playwright install chromium

Basic Recording Script

typescript
// scripts/record-demo.tsimport { chromium } from 'playwright';
async function recordDemo() {  const browser = await chromium.launch();  const context = await browser.newContext({    viewport: { width: 1920, height: 1080 },    recordVideo: {      dir: './recordings',      size: { width: 1920, height: 1080 }    }  });
  const page = await context.newPage();
  // Your recording actions  await page.goto('https://example.com');  await page.waitForTimeout(2000);  await page.click('button.demo');  await page.waitForTimeout(3000);
  // Close to save video  await context.close();  await browser.close();
  console.log('Recording saved to ./recordings/');}
recordDemo();

Run with:

bash
npx ts-node scripts/record-demo.ts# ornpx tsx scripts/record-demo.ts

Recording Configuration

Viewport Sizes

typescript
// Standard 1080p (recommended for Remotion)viewport: { width: 1920, height: 1080 }
// 720p (smaller files)viewport: { width: 1280, height: 720 }
// Square (social media)viewport: { width: 1080, height: 1080 }
// Mobileviewport: { width: 390, height: 844 } // iPhone 14

Video Quality Settings

typescript
const context = await browser.newContext({  viewport: { width: 1920, height: 1080 },  recordVideo: {    dir: './recordings',    size: { width: 1920, height: 1080 } // Match viewport for crisp output  },  // Slow down for visibility  // Note: slowMo is on browser launch, not context});
// For slow motion, launch browser with slowMoconst browser = await chromium.launch({  slowMo: 100 // 100ms delay between actions});

Recording Patterns

Form Submission Demo

typescript
import { chromium } from 'playwright';
async function recordFormDemo() {  const browser = await chromium.launch({ slowMo: 50 });  const context = await browser.newContext({    viewport: { width: 1920, height: 1080 },    recordVideo: { dir: './recordings', size: { width: 1920, height: 1080 } }  });  const page = await context.newPage();
  await page.goto('https://myapp.com/form');  await page.waitForTimeout(1000);
  // Type with realistic speed  await page.fill('#name', 'John Smith', { timeout: 5000 });  await page.waitForTimeout(500);
  await page.fill('#email', '[email protected]');  await page.waitForTimeout(500);
  // Click submit  await page.click('button[type="submit"]');
  // Wait for result  await page.waitForSelector('.success-message');  await page.waitForTimeout(2000);
  await context.close();  await browser.close();}

Multi-Page Navigation

typescript
async function recordNavDemo() {  const browser = await chromium.launch({ slowMo: 100 });  const context = await browser.newContext({    viewport: { width: 1920, height: 1080 },    recordVideo: { dir: './recordings', size: { width: 1920, height: 1080 } }  });  const page = await context.newPage();
  // Page 1  await page.goto('https://myapp.com');  await page.waitForTimeout(2000);
  // Navigate to page 2  await page.click('nav a[href="/features"]');  await page.waitForLoadState('networkidle');  await page.waitForTimeout(2000);
  // Navigate to page 3  await page.click('nav a[href="/pricing"]');  await page.waitForLoadState('networkidle');  await page.waitForTimeout(2000);
  await context.close();  await browser.close();}

Scroll Demo

typescript
async function recordScrollDemo() {  const browser = await chromium.launch();  const context = await browser.newContext({    viewport: { width: 1920, height: 1080 },    recordVideo: { dir: './recordings', size: { width: 1920, height: 1080 } }  });  const page = await context.newPage();
  await page.goto('https://myapp.com/long-page');  await page.waitForTimeout(1000);
  // Smooth scroll  await page.evaluate(async () => {    const delay = (ms: number) => new Promise(r => setTimeout(r, ms));    for (let i = 0; i < 10; i++) {      window.scrollBy({ top: 200, behavior: 'smooth' });      await delay(300);    }  });
  await page.waitForTimeout(1000);  await context.close();  await browser.close();}

Login Flow

typescript
async function recordLoginDemo() {  const browser = await chromium.launch({ slowMo: 75 });  const context = await browser.newContext({    viewport: { width: 1920, height: 1080 },    recordVideo: { dir: './recordings', size: { width: 1920, height: 1080 } }  });  const page = await context.newPage();
  await page.goto('https://myapp.com/login');  await page.waitForTimeout(1000);
  await page.fill('#email', '[email protected]');  await page.waitForTimeout(300);
  await page.fill('#password', '••••••••');  await page.waitForTimeout(500);
  await page.click('button[type="submit"]');
  // Wait for dashboard  await page.waitForURL('**/dashboard');  await page.waitForTimeout(3000);
  await context.close();  await browser.close();}

Cursor Highlighting

Playwright doesn't show cursor by default. Add visual indicators:

CSS Cursor Highlight

typescript
// Inject cursor visualizationawait page.addStyleTag({  content: `    * { cursor: none !important; }    .playwright-cursor {      position: fixed;      width: 24px;      height: 24px;      background: rgba(255, 100, 100, 0.5);      border: 2px solid rgba(255, 50, 50, 0.8);      border-radius: 50%;      pointer-events: none;      z-index: 999999;      transform: translate(-50%, -50%);      transition: transform 0.1s ease;    }    .playwright-cursor.clicking {      transform: translate(-50%, -50%) scale(0.8);      background: rgba(255, 50, 50, 0.8);    }  `});
// Add cursor elementawait page.evaluate(() => {  const cursor = document.createElement('div');  cursor.className = 'playwright-cursor';  document.body.appendChild(cursor);
  document.addEventListener('mousemove', (e) => {    cursor.style.left = e.clientX + 'px';    cursor.style.top = e.clientY + 'px';  });
  document.addEventListener('mousedown', () => cursor.classList.add('clicking'));  document.addEventListener('mouseup', () => cursor.classList.remove('clicking'));});

Click Ripple Effect

typescript
// Add click ripple visualizationawait page.addStyleTag({  content: `    .click-ripple {      position: fixed;      width: 40px;      height: 40px;      border-radius: 50%;      background: rgba(234, 88, 12, 0.4);      pointer-events: none;      z-index: 999998;      transform: translate(-50%, -50%) scale(0);      animation: ripple 0.4s ease-out forwards;    }    @keyframes ripple {      to {        transform: translate(-50%, -50%) scale(2);        opacity: 0;      }    }  `});
// Custom click function with rippleasync function clickWithRipple(page, selector) {  const element = await page.locator(selector);  const box = await element.boundingBox();
  await page.evaluate(({ x, y }) => {    const ripple = document.createElement('div');    ripple.className = 'click-ripple';    ripple.style.left = x + 'px';    ripple.style.top = y + 'px';    document.body.appendChild(ripple);    setTimeout(() => ripple.remove(), 400);  }, { x: box.x + box.width / 2, y: box.y + box.height / 2 });
  await element.click();}

Output for Remotion

Move Recording to public/demos/

typescript
import { chromium } from 'playwright';import * as fs from 'fs';import * as path from 'path';
async function recordForRemotion(outputName: string) {  const browser = await chromium.launch({ slowMo: 50 });  const context = await browser.newContext({    viewport: { width: 1920, height: 1080 },    recordVideo: { dir: './temp-recordings', size: { width: 1920, height: 1080 } }  });  const page = await context.newPage();
  // ... recording actions ...
  await context.close();
  // Get the video path  const video = page.video();  const videoPath = await video?.path();
  if (videoPath) {    const destPath = `./public/demos/${outputName}.webm`;    fs.mkdirSync(path.dirname(destPath), { recursive: true });    fs.renameSync(videoPath, destPath);    console.log(`Recording saved to: ${destPath}`);
    // Get duration for config    // Use ffprobe: ffprobe -v error -show_entries format=duration -of csv=p=0 file.webm  }
  await browser.close();}

Convert WebM to MP4

Playwright outputs WebM. Convert for better Remotion compatibility:

bash
ffmpeg -i recording.webm -c:v libx264 -crf 20 -preset medium -movflags faststart public/demos/demo.mp4

Interactive Recording

For user-driven recordings where you manually perform actions:

typescript
// Inject ESC key listener to stop recordingasync function injectStopListener(page: Page): Promise<void> {  await page.evaluate(() => {    if ((window as any).__escListenerAdded) return;    (window as any).__escListenerAdded = true;    (window as any).__stopRecording = false;    document.addEventListener('keydown', (e) => {      if (e.key === 'Escape') {        e.preventDefault();        (window as any).__stopRecording = true;      }    });  });}
// Poll for stop signal - handle navigation errors gracefullywhile (!stopped) {  try {    const shouldStop = await page.evaluate(() => (window as any).__stopRecording === true);    if (shouldStop) break;  } catch {    // Page navigating - continue recording  }  await new Promise(r => setTimeout(r, 200));}

Key insight: page.evaluate() throws during navigation. Use try/catch and continue - don't treat errors as stop signals.

Window Scaling for Laptops

Record at full 1080p while showing a smaller window:

typescript
const scale = 0.75; // 75% window sizeconst context = await browser.newContext({  viewport: { width: 1920 * scale, height: 1080 * scale },  deviceScaleFactor: 1 / scale,  recordVideo: { dir: './recordings', size: { width: 1920, height: 1080 } },});

Cookie Banner Dismissal

Comprehensive selector list for common consent platforms:

typescript
const COOKIE_SELECTORS = [  '#onetrust-accept-btn-handler',           // OneTrust  '#CybotCookiebotDialogBodyButtonAccept',  // Cookiebot  '.cc-btn.cc-dismiss',                      // Cookie Consent by Insites  '[class*="cookie"] button[class*="accept"]',  '[class*="consent"] button[class*="accept"]',  'button:has-text("Accept all")',  'button:has-text("Accept cookies")',  'button:has-text("Got it")',];
async function dismissCookieBanners(page: Page): Promise<void> {  await page.waitForTimeout(500);  for (const selector of COOKIE_SELECTORS) {    try {      const btn = page.locator(selector).first();      if (await btn.isVisible({ timeout: 100 })) {        await btn.click({ timeout: 500 });        return;      }    } catch { /* try next */ }  }}

Call after page.goto() and on page.on('load') for navigation.

Important: Injected Elements Appear in Video

Warning: Any DOM elements you inject (cursors, control panels, overlays) will be recorded. For UI-free recordings, use terminal-based controls only (Ctrl+C, max duration timer).

Tips for Good Demo Recordings

  1. Use slowMo - 50-100ms makes actions visible
  2. Add waitForTimeout - Pause between actions for comprehension
  3. Wait for animations - Use waitForLoadState('networkidle')
  4. Match Remotion dimensions - 1920x1080 at 30fps typical
  5. Test without recording first - Debug before final capture
  6. Clear browser state - Use fresh context for clean demos
  7. Dismiss cookie banners - Use comprehensive selector list above
  8. Re-inject on navigation - Cursor/listeners reset on page load

Feedback & Contributions

If this skill is missing information or could be improved:

  • Missing a pattern? Describe what you needed
  • Found an error? Let me know what's wrong
  • Want to contribute? I can help you:
    1. Update this skill with improvements
    2. Create a PR to github.com/digitalsamba/claude-code-video-toolkit

Just say "improve this skill" and I'll guide you through updating .claude/skills/playwright-recording/SKILL.md.

Source and attribution

Source:digitalsamba/claude-code-video-toolkitin.claude/skills/playwright-recordingat commit2c99460

License: No license

Content belongs to its original authors. SourceWeft indexes it from a public repository.

Report or request removal