Visual regression testing with Playwright and Percy. Use when implementing screenshot-based testing.
This skill inherits all available tools. When active, it can use any tool Claude has access to.
This skill covers visual regression testing patterns for React applications.
Use this skill when:
CATCH VISUAL BUGS - Visual regression tests detect unintended changes to UI appearance that functional tests miss.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests/visual',
snapshotDir: './tests/visual/__snapshots__',
updateSnapshots: 'missing',
expect: {
toHaveScreenshot: {
maxDiffPixels: 100,
threshold: 0.2,
},
},
projects: [
{
name: 'Desktop Chrome',
use: {
browserName: 'chromium',
viewport: { width: 1280, height: 720 },
},
},
{
name: 'Mobile Safari',
use: {
browserName: 'webkit',
viewport: { width: 375, height: 667 },
},
},
],
});
import { test, expect } from '@playwright/test';
test.describe('Visual Regression', () => {
test('homepage matches snapshot', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot('homepage.png');
});
test('dashboard matches snapshot', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot('dashboard.png');
});
});
test('button component variants', async ({ page }) => {
await page.goto('/storybook/button');
// Screenshot specific element
const button = page.getByRole('button', { name: 'Primary' });
await expect(button).toHaveScreenshot('button-primary.png');
// Screenshot component container
const container = page.locator('[data-testid="button-variants"]');
await expect(container).toHaveScreenshot('button-variants.png');
});
test('page with dynamic content', async ({ page }) => {
await page.goto('/profile');
// Mask dynamic elements
await expect(page).toHaveScreenshot('profile.png', {
mask: [
page.locator('[data-testid="timestamp"]'),
page.locator('[data-testid="avatar"]'),
page.locator('.dynamic-ad'),
],
});
});
test('page with animations', async ({ page }) => {
await page.goto('/animated-page');
// Disable animations
await page.emulateMedia({ reducedMotion: 'reduce' });
// Or wait for animations to complete
await page.waitForFunction(() => {
const animations = document.getAnimations();
return animations.every((a) => a.playState === 'finished');
});
await expect(page).toHaveScreenshot('animated-page.png');
});
const viewports = [
{ width: 375, height: 667, name: 'mobile' },
{ width: 768, height: 1024, name: 'tablet' },
{ width: 1280, height: 720, name: 'desktop' },
{ width: 1920, height: 1080, name: 'wide' },
];
test.describe('Responsive layouts', () => {
for (const viewport of viewports) {
test(`homepage at ${viewport.name}`, async ({ page }) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await page.goto('/');
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot(`homepage-${viewport.name}.png`);
});
}
});
test('full page screenshot', async ({ page }) => {
await page.goto('/long-page');
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot('long-page-full.png', {
fullPage: true,
});
});
npm install -D @percy/cli @percy/playwright
# .percy.yml
version: 2
snapshot:
widths:
- 375
- 768
- 1280
minHeight: 1024
percyCSS: |
.hide-in-percy {
visibility: hidden;
}
discovery:
allowedHostnames:
- localhost
- cdn.example.com
import { test } from '@playwright/test';
import percySnapshot from '@percy/playwright';
test.describe('Percy Visual Tests', () => {
test('homepage', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
await percySnapshot(page, 'Homepage');
});
test('dashboard states', async ({ page }) => {
await page.goto('/dashboard');
// Empty state
await percySnapshot(page, 'Dashboard - Empty');
// With data
await page.evaluate(() => {
// Inject mock data
});
await percySnapshot(page, 'Dashboard - With Data');
// Loading state
await page.evaluate(() => {
document.body.classList.add('loading');
});
await percySnapshot(page, 'Dashboard - Loading');
});
});
npm install -D @percy/storybook
// .storybook/main.js
module.exports = {
addons: ['@percy/storybook'],
};
# Run Percy on Storybook
npx percy storybook ./storybook-static
// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
title: 'Components/Button',
component: Button,
parameters: {
chromatic: { disableSnapshot: false },
},
};
export default meta;
type Story = StoryObj<typeof Button>;
export const AllVariants: Story = {
render: () => (
<div className="flex flex-col gap-4">
<div className="flex gap-2">
<Button variant="default">Default</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="destructive">Destructive</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
</div>
<div className="flex gap-2">
<Button size="sm">Small</Button>
<Button size="default">Default</Button>
<Button size="lg">Large</Button>
</div>
<div className="flex gap-2">
<Button disabled>Disabled</Button>
<Button isLoading>Loading</Button>
</div>
</div>
),
};
// Interaction states for visual testing
export const HoverState: Story = {
args: { children: 'Hover Me' },
parameters: {
pseudo: { hover: true },
},
};
export const FocusState: Story = {
args: { children: 'Focus Me' },
parameters: {
pseudo: { focus: true },
},
};
// tests/visual/fixtures.ts
import { test as base } from '@playwright/test';
interface VisualTestFixtures {
visualPage: typeof page;
}
export const test = base.extend<VisualTestFixtures>({
visualPage: async ({ page }, use) => {
// Disable animations globally
await page.addStyleTag({
content: `
*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
}
`,
});
// Set consistent date/time
await page.addInitScript(() => {
const fixedDate = new Date('2024-01-15T10:00:00Z');
// @ts-ignore
Date = class extends Date {
constructor(...args: []) {
if (args.length === 0) {
super(fixedDate);
} else {
// @ts-ignore
super(...args);
}
}
static now() {
return fixedDate.getTime();
}
};
});
await use(page);
},
});
export { expect } from '@playwright/test';
test.describe('Dark mode visual tests', () => {
test('homepage in light mode', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('homepage-light.png');
});
test('homepage in dark mode', async ({ page }) => {
await page.goto('/');
// Enable dark mode
await page.evaluate(() => {
document.documentElement.classList.add('dark');
});
await expect(page).toHaveScreenshot('homepage-dark.png');
});
test('component in both modes', async ({ page }) => {
await page.goto('/components/card');
const card = page.locator('[data-testid="card"]');
// Light mode
await expect(card).toHaveScreenshot('card-light.png');
// Dark mode
await page.evaluate(() => {
document.documentElement.classList.add('dark');
});
await expect(card).toHaveScreenshot('card-dark.png');
});
});
# .github/workflows/visual.yml
name: Visual Regression
on: [push, pull_request]
jobs:
visual-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run visual tests
run: npx playwright test tests/visual/
- name: Upload snapshots
uses: actions/upload-artifact@v4
if: failure()
with:
name: visual-snapshots
path: |
tests/visual/__snapshots__/
test-results/
# .github/workflows/percy.yml
name: Percy
on: [push, pull_request]
jobs:
percy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Percy snapshot
run: npx percy exec -- npx playwright test tests/visual/
env:
PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}
# Run visual tests
npx playwright test tests/visual/
# Update snapshots
npx playwright test tests/visual/ --update-snapshots
# Run specific visual test
npx playwright test tests/visual/homepage.spec.ts
# Run with Percy
npx percy exec -- npx playwright test tests/visual/
# View report
npx playwright show-report