
Playwright Recording
- 842 installs
- 1.8k repo stars
- Updated July 6, 2026
- digitalsamba/claude-code-video-toolkit
playwright-recording is a Playwright reference skill that captures high-quality browser session videos during automated tests for developers who need visual proof of UI flows.
About
playwright-recording documents the Playwright `recordVideo` browser context option from digitalsamba/claude-code-video-toolkit for automatic session video capture. Developers pass a required `dir` output path and optional `size` width/height to `browser.newContext({ recordVideo })`, alongside viewport, colorScheme, locale, timezoneId, geolocation, permissions, and userAgent settings. Reach for playwright-recording when E2E tests or agent-driven browser automation need MP4 artifacts for bug reports, demo reels, or regression forensics instead of manual screen recording. The skill is API-reference focused rather than a standalone test runner.
- Full control over Playwright recordVideo options including output directory, viewport size, and dimensions
- Browser launch configuration with slowMo, headless toggling, devtools, and Chromium command-line flags
- Page-level recording methods supporting navigation, explicit waits, selectors, and user interactions
- Context-level options for colorScheme, locale, timezone, geolocation, permissions and custom userAgent
- Designed for reproducible visual regression and demo recording inside Claude Code and Cursor agents
Playwright Recording by the numbers
- 842 all-time installs (skills.sh)
- +26 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #555 of 2,184 Testing & QA skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/digitalsamba/claude-code-video-toolkit --skill playwright-recordingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 842 |
|---|---|
| repo stars | ★ 1.8k |
| Security audit | 1 / 3 scanners passed |
| Last updated | July 6, 2026 |
| Repository | digitalsamba/claude-code-video-toolkit ↗ |
How do you record Playwright browser test sessions as video?
Automatically capture high-quality video recordings of browser sessions during automated testing and agent-driven UI flows.
Who is it for?
Developers adding automatic video capture to Playwright E2E suites or agent-driven UI automation.
Skip if: Non-Playwright frameworks or production runtime screen recording outside test contexts.
When should I use this skill?
A Playwright test or agent browser flow needs automatic video recording with configurable dimensions and output directory.
What you get
MP4 video files saved to a configured directory from Playwright browser context sessions.
- MP4 browser session videos
- Configured Playwright browser contexts
Files
Playwright Video Recording
Playwright can record browser interactions as video - perfect for demo footage in Remotion compositions.
Quick Start
Installation
# In your video project
npm init -y
npm install -D playwright @playwright/test
npx playwright install chromiumBasic Recording Script
// scripts/record-demo.ts
import { 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:
npx ts-node scripts/record-demo.ts
# or
npx tsx scripts/record-demo.tsRecording Configuration
Viewport Sizes
// 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 }
// Mobile
viewport: { width: 390, height: 844 } // iPhone 14Video Quality Settings
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 slowMo
const browser = await chromium.launch({
slowMo: 100 // 100ms delay between actions
});Recording Patterns
Form Submission Demo
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', 'john@example.com');
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
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
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
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', 'demo@example.com');
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
// Inject cursor visualization
await 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 element
await 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
// Add click ripple visualization
await 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 ripple
async 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/
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:
ffmpeg -i recording.webm -c:v libx264 -crf 20 -preset medium -movflags faststart public/demos/demo.mp4Interactive Recording
For user-driven recordings where you manually perform actions:
// Inject ESC key listener to stop recording
async 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 gracefully
while (!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:
const scale = 0.75; // 75% window size
const 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:
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.
Playwright Recording Reference
API Reference
Browser Context Video Options
interface RecordVideoOptions {
dir: string; // Output directory (required)
size?: { width: number; height: number }; // Video dimensions
}
const context = await browser.newContext({
viewport: { width: number; height: number };
recordVideo: RecordVideoOptions;
// Other useful options:
colorScheme?: 'light' | 'dark' | 'no-preference';
locale?: string; // e.g., 'en-US'
timezoneId?: string; // e.g., 'America/New_York'
geolocation?: { latitude: number; longitude: number };
permissions?: string[]; // e.g., ['geolocation']
userAgent?: string;
});Browser Launch Options
const browser = await chromium.launch({
slowMo?: number; // Slow down actions by ms
headless?: boolean; // Default true, set false to see browser
devtools?: boolean; // Open devtools
args?: string[]; // Chromium flags
});
// Useful args:
args: [
'--start-maximized',
'--disable-infobars',
'--hide-scrollbars',
]Page Methods for Recording
// Navigation
await page.goto(url, { waitUntil?: 'load' | 'domcontentloaded' | 'networkidle' });
await page.goBack();
await page.goForward();
await page.reload();
// Waiting
await page.waitForTimeout(ms);
await page.waitForLoadState('networkidle');
await page.waitForSelector(selector);
await page.waitForURL(urlPattern);
// Interactions
await page.click(selector);
await page.dblclick(selector);
await page.fill(selector, value);
await page.type(selector, text); // Types character by character
await page.press(selector, key); // e.g., 'Enter', 'Tab'
await page.hover(selector);
await page.selectOption(selector, value);
await page.check(selector); // Checkbox
await page.uncheck(selector);
// Scrolling
await page.evaluate(() => window.scrollTo(0, 500));
await page.evaluate(() => window.scrollBy(0, 200));
await page.locator(selector).scrollIntoViewIfNeeded();
// Screenshots (for thumbnails)
await page.screenshot({ path: 'screenshot.png' });
await page.screenshot({ path: 'full.png', fullPage: true });Getting Video After Recording
const page = await context.newPage();
// ... do stuff ...
await context.close();
// Get video path
const video = page.video();
const path = await video?.path();
// Or save to specific location
await video?.saveAs('output.webm');
// Delete video
await video?.delete();Common Selectors
// CSS Selectors
await page.click('button');
await page.click('#submit-btn');
await page.click('.primary-button');
await page.click('[data-testid="login"]');
await page.click('button:has-text("Submit")');
// Text selectors
await page.click('text=Click me');
await page.click('text="Exact match"');
// XPath
await page.click('xpath=//button[@type="submit"]');
// Combining
await page.click('form >> button.submit');
await page.click('div.modal >> text=Confirm');Timing Utilities
// Reusable delay function
const delay = (ms: number) => new Promise(r => setTimeout(r, ms));
// Smooth typing with delays
async function typeSlowly(page, selector, text, delayMs = 100) {
await page.click(selector);
for (const char of text) {
await page.keyboard.type(char);
await delay(delayMs);
}
}
// Wait for animation to complete
async function waitForAnimation(page, selector) {
await page.waitForFunction(
(sel) => {
const el = document.querySelector(sel);
if (!el) return false;
const style = getComputedStyle(el);
return style.animationName === 'none' || style.animationPlayState === 'paused';
},
selector
);
}Device Emulation
import { devices } from 'playwright';
// iPhone
const context = await browser.newContext({
...devices['iPhone 14'],
recordVideo: { dir: './recordings' }
});
// iPad
const context = await browser.newContext({
...devices['iPad Pro 11'],
recordVideo: { dir: './recordings' }
});
// Available devices (partial list):
// 'Desktop Chrome', 'Desktop Firefox', 'Desktop Safari'
// 'iPhone 14', 'iPhone 14 Pro Max', 'iPhone SE'
// 'iPad Pro 11', 'iPad Mini'
// 'Pixel 7', 'Galaxy S23'Handling Common Scenarios
Cookie Consent Banner
// Option 1: Click accept
try {
await page.click('button:has-text("Accept")', { timeout: 3000 });
} catch {
// Banner not present
}
// Option 2: Hide with CSS
await page.addStyleTag({
content: `
[class*="cookie"], [id*="cookie"],
[class*="consent"], [id*="consent"],
[class*="gdpr"], [id*="gdpr"] {
display: none !important;
}
`
});Login Before Recording
// Save auth state
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('https://app.com/login');
await page.fill('#email', 'user@example.com');
await page.fill('#password', 'password');
await page.click('button[type="submit"]');
await page.waitForURL('**/dashboard');
// Save storage state
await context.storageState({ path: 'auth.json' });
await context.close();
// Use saved auth for recording
const recordingContext = await browser.newContext({
storageState: 'auth.json',
recordVideo: { dir: './recordings', size: { width: 1920, height: 1080 } }
});Handling Popups/Modals
// Wait for modal and interact
await page.click('button.open-modal');
await page.waitForSelector('.modal.visible');
await page.fill('.modal input', 'value');
await page.click('.modal button.submit');
await page.waitForSelector('.modal', { state: 'hidden' });File Upload
// Single file
await page.setInputFiles('input[type="file"]', 'path/to/file.pdf');
// Multiple files
await page.setInputFiles('input[type="file"]', ['file1.pdf', 'file2.pdf']);Recording Script Template
// scripts/record-[name].ts
import { chromium } from 'playwright';
import * as fs from 'fs';
import * as path from 'path';
const CONFIG = {
url: 'https://example.com',
outputName: 'demo-name',
viewport: { width: 1920, height: 1080 },
slowMo: 50,
};
async function record() {
console.log(`Starting recording: ${CONFIG.outputName}`);
const browser = await chromium.launch({
slowMo: CONFIG.slowMo,
headless: true,
});
const context = await browser.newContext({
viewport: CONFIG.viewport,
recordVideo: {
dir: './temp-recordings',
size: CONFIG.viewport,
},
});
const page = await context.newPage();
try {
// === RECORDING ACTIONS START ===
await page.goto(CONFIG.url);
await page.waitForTimeout(2000);
// Add your actions here...
await page.waitForTimeout(2000);
// === RECORDING ACTIONS END ===
} catch (error) {
console.error('Recording failed:', error);
} finally {
await context.close();
// Move video to public/demos
const video = page.video();
const videoPath = await video?.path();
if (videoPath) {
const destDir = './public/demos';
fs.mkdirSync(destDir, { recursive: true });
const destPath = path.join(destDir, `${CONFIG.outputName}.webm`);
fs.renameSync(videoPath, destPath);
console.log(`✓ Saved: ${destPath}`);
// Reminder to convert
console.log(`\nConvert to MP4 for Remotion:`);
console.log(`ffmpeg -i ${destPath} -c:v libx264 -crf 20 -movflags faststart ${destPath.replace('.webm', '.mp4')}`);
}
await browser.close();
}
}
record();Duration Calculation
After recording, get duration for Remotion config:
# Get duration in seconds
ffprobe -v error -show_entries format=duration -of csv=p=0 recording.webm
# Calculate frames (30fps)
# duration_seconds * 30 = frames// In Node.js
import { execSync } from 'child_process';
function getVideoDuration(filePath: string): number {
const output = execSync(
`ffprobe -v error -show_entries format=duration -of csv=p=0 "${filePath}"`
).toString().trim();
return parseFloat(output);
}
function getFrameCount(filePath: string, fps = 30): number {
const duration = getVideoDuration(filePath);
return Math.ceil(duration * fps);
}Related skills
FAQ
What Playwright option enables session video recording?
playwright-recording uses `browser.newContext({ recordVideo: { dir, size } })` where `dir` is the required output directory and `size` optionally sets width and height.
Can playwright-recording set locale and timezone for videos?
playwright-recording documents passing `locale` (e.g. en-US), `timezoneId` (e.g. America/New_York), colorScheme, and geolocation on the same Playwright browser context as recordVideo.
Is Playwright Recording safe to install?
skills.sh reports 1 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.