Why Mobile Screenshots Matter
Over 60% of web traffic is mobile. Visual bugs on mobile — collapsed navbars, overflowing text, broken touch targets — are often only caught when someone manually checks on a real device. Playwright's device emulation lets you automate this: capture screenshots at any device profile, at any breakpoint, in CI.
Built-in Device Presets
Playwright ships with 30+ device descriptors covering iPhones, iPads, Android phones, and Pixel devices. Each preset includes the correct viewport, User-Agent, DPR (device pixel ratio), and touch support flags.
import { chromium, devices } from 'playwright';
const browser = await chromium.launch();
// iPhone 15 Pro — 393×852, DPR 3, iOS UA
const context = await browser.newContext({
...devices['iPhone 15 Pro'],
});
const page = await context.newPage();
await page.goto('https://example.com', { waitUntil: 'networkidle' });
await page.screenshot({ path: 'iphone15pro.png' });
await browser.close();
Popular device presets
iPhone 15 Pro
393×852 · DPR 3 · iOS
iPhone SE
375×667 · DPR 2 · iOS
iPad Pro 11
834×1194 · DPR 2 · iPadOS
Pixel 7
412×915 · DPR 2.625 · Android
Galaxy S23
360×780 · DPR 3 · Android
Galaxy Tab S8
753×1205 · DPR 2 · Android
// List all available device names
import { devices } from 'playwright';
console.log(Object.keys(devices));
// ['Blackberry PlayBook', 'Galaxy S III', 'Galaxy S5', 'Galaxy S8', ...]
Custom Viewport & DPR
For custom dimensions not covered by a preset, set viewport and DPR manually:
const context = await browser.newContext({
viewport: { width: 390, height: 844 }, // iPhone 14 dimensions
deviceScaleFactor: 3, // retina (3x) → output is 1170×2532px
isMobile: true,
hasTouch: true,
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
});
DPR matters: At DPR 3 with a 390×844 viewport, your screenshot will be 1170×2532 pixels — actual retina resolution. Without DPR, screenshots look blurry when displayed at device size. Always set deviceScaleFactor to match the target device.
Screenshot Across Multiple Devices
import { chromium, devices } from 'playwright';
import fs from 'fs/promises';
const targetDevices = [
'iPhone 15 Pro',
'iPhone SE',
'iPad Pro 11',
'Pixel 7',
'Galaxy S23',
];
const browser = await chromium.launch();
await Promise.all(
targetDevices.map(async (deviceName) => {
const context = await browser.newContext({
...devices[deviceName],
});
const page = await context.newPage();
await page.goto('https://example.com', { waitUntil: 'networkidle' });
const filename = deviceName.toLowerCase().replace(/\s+/g, '-') + '.png';
await page.screenshot({ path: `screenshots/${filename}`, fullPage: true });
await context.close();
console.log(`✓ ${deviceName} → ${filename}`);
})
);
await browser.close();
Geolocation & Locale
Some sites serve different content based on location — price currencies, language, consent banners. Pair geolocation with device emulation for region-specific screenshots:
const context = await browser.newContext({
...devices['iPhone 15 Pro'],
geolocation: { latitude: 51.5074, longitude: -0.1278 }, // London
locale: 'en-GB',
timezoneId: 'Europe/London',
permissions: ['geolocation'],
});
const page = await context.newPage();
await page.goto('https://example.com/pricing');
await page.screenshot({ path: 'london-pricing.png' });
Dark Mode & Color Scheme
// Force dark mode
const darkContext = await browser.newContext({
...devices['iPhone 15 Pro'],
colorScheme: 'dark',
});
// Force light mode (override user preference)
const lightContext = await browser.newContext({
...devices['iPhone 15 Pro'],
colorScheme: 'light',
});
// Take both for comparison
const darkPage = await darkContext.newPage();
const lightPage = await lightContext.newPage();
await Promise.all([
darkPage.goto('https://example.com'),
lightPage.goto('https://example.com'),
]);
await Promise.all([
darkPage.screenshot({ path: 'dark.png' }),
lightPage.screenshot({ path: 'light.png' }),
]);
Responsive Breakpoint Testing
Test your layout at every major breakpoint in a single script:
const breakpoints = [
{ name: 'mobile-sm', width: 320, height: 568 },
{ name: 'mobile-md', width: 375, height: 812 },
{ name: 'mobile-lg', width: 428, height: 926 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'laptop', width: 1280, height: 800 },
{ name: 'desktop', width: 1920, height: 1080 },
];
const browser = await chromium.launch();
for (const bp of breakpoints) {
const context = await browser.newContext({
viewport: { width: bp.width, height: bp.height },
deviceScaleFactor: bp.width < 768 ? 2 : 1,
isMobile: bp.width < 768,
hasTouch: bp.width < 768,
});
const page = await context.newPage();
await page.goto('https://example.com', { waitUntil: 'networkidle' });
await page.screenshot({
path: `screenshots/${bp.name}-${bp.width}x${bp.height}.png`,
fullPage: true,
});
await context.close();
}
await browser.close();
Touch Interactions Before Screenshot
Mobile layouts often have collapsible menus, accordions, or tabs that only activate on touch. Use tap() to interact before capturing:
const context = await browser.newContext({
...devices['iPhone 15 Pro'],
});
const page = await context.newPage();
await page.goto('https://example.com');
// Open hamburger menu
await page.tap('#hamburger-menu');
await page.waitForSelector('#nav-menu.open');
await page.screenshot({ path: 'menu-open.png' });
// Close it and capture the base state
await page.tap('#hamburger-menu');
await page.screenshot({ path: 'menu-closed.png' });
Mobile Visual Regression in CI
Playwright's built-in toHaveScreenshot() works perfectly with device contexts for automated regression detection:
// tests/mobile.spec.ts
import { test, devices } from '@playwright/test';
const iphone = devices['iPhone 15 Pro'];
test.use({ ...iphone });
test('homepage mobile layout', async ({ page }) => {
await page.goto('https://example.com');
await expect(page).toHaveScreenshot('homepage-mobile.png', {
maxDiffPixelRatio: 0.02, // 2% pixel difference allowed
});
});
test('pricing page mobile', async ({ page }) => {
await page.goto('https://example.com/pricing');
await expect(page).toHaveScreenshot('pricing-mobile.png');
});
# playwright.config.ts — multi-project mobile/desktop config
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
{ name: 'desktop', use: { viewport: { width: 1280, height: 800 } } },
{ name: 'mobile', use: { ...devices['iPhone 15 Pro'] } },
{ name: 'tablet', use: { ...devices['iPad Pro 11'] } },
{ name: 'android', use: { ...devices['Pixel 7'] } },
],
snapshotPathTemplate: '{testDir}/snapshots/{testFilePath}/{arg}-{projectName}{ext}',
});
SnapAPI: Mobile Screenshots Without Playwright
Running Playwright locally is great for development, but for production screenshot services — where you need to capture URLs on demand — SnapAPI handles device emulation via API with 30+ presets:
// No Playwright install, no browser management
const res = await fetch('https://api.snapapi.pics/v1/screenshot', {
method: 'POST',
headers: {
'X-Api-Key': process.env.SNAPAPI_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: 'https://example.com',
device: 'iPhone 15 Pro', // or 'Pixel 7', 'iPad Air 5', 'Galaxy S23', etc.
fullPage: true,
format: 'png',
}),
});
const buffer = Buffer.from(await res.arrayBuffer());
# Python — screenshot across 4 devices in parallel
import httpx, asyncio
API_KEY = "your_key"
DEVICES = ["iPhone 15 Pro", "Pixel 7", "iPad Pro 11", "Galaxy S23"]
async def capture(client, device):
r = await client.post(
"https://api.snapapi.pics/v1/screenshot",
json={"url": "https://example.com", "device": device, "fullPage": True},
headers={"X-Api-Key": API_KEY},
timeout=30,
)
with open(f"{device.lower().replace(' ', '-')}.png", "wb") as f:
f.write(r.content)
print(f"✓ {device}")
async def main():
async with httpx.AsyncClient() as client:
await asyncio.gather(*[capture(client, d) for d in DEVICES])
asyncio.run(main())
Playwright vs SnapAPI for Mobile Screenshots
| Need | Playwright | SnapAPI |
| Local dev / CI testing | ✅ Best choice | Fine |
| Visual regression tests | ✅ toHaveScreenshot() | Pair with pixelmatch |
| On-demand production API | Need to host browser | ✅ REST call |
| 30+ device presets | ✅ Built-in | ✅ Built-in |
| Serverless / Lambda | @sparticuz/chromium | ✅ Native |
| Stealth / bot bypass | playwright-extra | ✅ stealth: true |
| Zero infrastructure | ❌ | ✅ |