Playwright Mobile Screenshots in 2026

Capture pixel-perfect mobile screenshots with Playwright device emulation — iOS, Android, tablets — including DPR scaling, geolocation, dark mode, and responsive testing across breakpoints.

PlaywrightMobileDevice Emulation Responsive TestingApril 2026

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

NeedPlaywrightSnapAPI
Local dev / CI testing✅ Best choiceFine
Visual regression tests✅ toHaveScreenshot()Pair with pixelmatch
On-demand production APINeed to host browser✅ REST call
30+ device presets✅ Built-in✅ Built-in
Serverless / Lambda@sparticuz/chromium✅ Native
Stealth / bot bypassplaywright-extra✅ stealth: true
Zero infrastructure
SnapAPI free tier: 200 captures/month with device emulation included. Get your API key →