---
name: playwright-visual-regression
description: Use Playwright's built-in visual comparisons (toHaveScreenshot) for stable visual regression testing across viewports and dark/light modes. Use when adding visual tests for design-system components, marketing pages, or any UI where pixel changes matter. Covers font-loading determinism, animation pauses, and CI-vs-local diff thresholds.
category: testing
version: 0.1.0
tags: [playwright, visual-regression, e2e, test, screenshot]
recommended_npm: ["@playwright/test"]
license: MIT
author: claude-code-skills
---

Playwright's `toHaveScreenshot()` is good enough for most visual regression needs without paid services (Percy, Chromatic). The trick is making it deterministic.

## The 4 deterministic-screenshot rules

1. **Pin browser version per-CI.** Don't let the runner update Chromium between PRs.
2. **Wait for fonts to load.** `await page.evaluate(() => document.fonts.ready);` before every screenshot.
3. **Pause animations.** Use Playwright's `animations: "disabled"` option.
4. **Mask volatile regions.** Timestamps, randomly-generated IDs, ads, charts with random data — `mask`.

## Setup

```ts
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  testDir: "./tests/visual",
  fullyParallel: true,
  reporter: [["html", { open: "never" }], ["list"]],
  expect: {
    toHaveScreenshot: {
      maxDiffPixelRatio: 0.002,        // 0.2% of pixels can differ
      animations: "disabled",
      caret: "hide",
      stylePath: "./tests/visual/_disable-animations.css",
    },
  },
  use: {
    baseURL: "http://localhost:3000",
    trace: "retain-on-failure",
  },
  projects: [
    { name: "chromium-desktop", use: { ...devices["Desktop Chrome"], viewport: { width: 1440, height: 900 } } },
    { name: "chromium-mobile", use: { ...devices["Pixel 7"] } },
    { name: "webkit-desktop", use: { ...devices["Desktop Safari"], viewport: { width: 1440, height: 900 } } },
  ],
  snapshotPathTemplate: "{testDir}/__screenshots__/{testFilePath}/{arg}-{projectName}{ext}",
});
```

```css
/* tests/visual/_disable-animations.css */
*, *::before, *::after {
  animation-duration: 0s !important;
  transition-duration: 0s !important;
  caret-color: transparent !important;
}
```

## Test pattern

```ts
import { expect, test } from "@playwright/test";

test.beforeEach(async ({ page }) => {
  await page.goto("/");
  await page.evaluate(() => document.fonts.ready);
});

test("hero section — light", async ({ page }) => {
  await expect(page.locator("section.hero")).toHaveScreenshot("hero-light.png");
});

test("hero section — dark", async ({ page }) => {
  await page.emulateMedia({ colorScheme: "dark" });
  await page.goto("/");
  await page.evaluate(() => document.fonts.ready);
  await expect(page.locator("section.hero")).toHaveScreenshot("hero-dark.png", {
    mask: [page.locator("[data-testid=relative-time]")],
  });
});
```

## Updating baselines

```bash
# After an intentional UI change
npx playwright test --update-snapshots tests/visual/hero.spec.ts

# Review the diff in the test report
npx playwright show-report
```

Always `--update-snapshots` for *one* test at a time, eyeball the diff, then commit. Bulk regenerate is how visual regressions slip through.

## CI configuration

```yaml
- name: Install Playwright browsers
  run: npx playwright install --with-deps chromium webkit

- name: Run visual tests
  run: npx playwright test --reporter=html

- name: Upload test results
  if: always()
  uses: actions/upload-artifact@v4
  with:
    name: playwright-report
    path: playwright-report/
    retention-days: 14
```

## Anti-patterns

- ❌ Snapshotting full pages with dynamic content (latest news, charts) — every run fails. Snapshot stable regions only.
- ❌ Different OSes for snapshot creation (macOS dev, Linux CI) — font rendering differs. Generate baselines on the same OS as CI.
- ❌ `maxDiffPixelRatio: 0` — anti-aliasing differences will fail every PR. Allow 0.1-0.5%.
- ❌ Snapshotting `<video>` elements — frame-perfect is impossible. Mask them.
- ❌ Running visual tests on every PR file change — slow. Run only when frontend code or stylesheets change (use `paths:` filter in Actions).
- ❌ Committing baselines without reviewing the diff — defeats the test.

## Quality gates

- Same screenshot generated locally and in CI matches within `maxDiffPixelRatio`.
- Fonts loaded before screenshot (verified by `document.fonts.ready` await).
- Volatile regions explicitly masked.
- Test runtime under 90s for ~30 components.
- Baselines reviewed in PR via Playwright HTML report.
