Pixel-perfect comparison sounds great in theory. In practice, it is a nightmare. Anti-aliasing renders slightly differently. A font smoothing algorithm changes by 1 pixel. A blinking cursor appears in one run but not another. Without tuning, your visual tests will be the flakiest tests in your suite.
| Option | Type | Default | What It Does |
|---|---|---|---|
| maxDiffPixels | number | 0 | Allow up to N pixels to differ |
| maxDiffPixelRatio | number | 0 | Allow a percentage of pixels to differ (0.01 = 1%) |
| threshold | number | 0.2 | Color difference tolerance per pixel (0 = exact, 1 = any color) |
| animations | "allow" | "disabled" | "disabled" | Whether to capture mid-animation frames |
| caret | "hide" | "initial" | "hide" | Hide the blinking text cursor |
| scale | "css" | "device" | "css" | Screenshot scale factor |
| fullPage | boolean | false | Capture entire scrollable page |
Use maxDiffPixels when you know a small area might change -- like a clock widget that is 50 pixels. Use maxDiffPixelRatio when the page size varies and you want a percentage-based tolerance.
import { test, expect } from '@playwright/test';
test('with pixel tolerance', async ({ page }) => {
await page.goto('/banking');
// Allow up to 100 pixels to differ
// Good for small anti-aliasing differences
await expect(page).toHaveScreenshot('dashboard.png', {
maxDiffPixels: 100,
});
});
test('with percentage tolerance', async ({ page }) => {
await page.goto('/banking');
// Allow 1% of total pixels to differ
// Better for pages of varying sizes
await expect(page).toHaveScreenshot('dashboard-ratio.png', {
maxDiffPixelRatio: 0.01,
});
});threshold controls how different a pixel color must be to count as "changed." It uses the YIQ color space. 0 means exact match. 0.2 (default) allows slight variations. 1 means any color counts as a match.
test('strict color comparison', async ({ page }) => {
await page.goto('/banking');
// Very strict -- even tiny color shifts fail
await expect(page).toHaveScreenshot('strict.png', {
threshold: 0.1,
});
});
test('lenient color comparison', async ({ page }) => {
await page.goto('/banking');
// More forgiving -- good for anti-aliased text
await expect(page).toHaveScreenshot('lenient.png', {
threshold: 0.3,
maxDiffPixelRatio: 0.02,
});
});test('handle animations and cursor', async ({ page }) => {
await page.goto('/banking/login');
// Default: animations disabled, caret hidden
// Playwright stops CSS animations and hides the blinking cursor
await expect(page).toHaveScreenshot('login-stable.png', {
animations: 'disabled', // freeze CSS animations
caret: 'hide', // hide blinking cursor
});
});Set defaults globally so you do not repeat options in every test.
import { defineConfig } from '@playwright/test';
export default defineConfig({
expect: {
toHaveScreenshot: {
maxDiffPixelRatio: 0.01, // Allow 1% diff globally
threshold: 0.2, // Color tolerance
animations: 'disabled', // Freeze all CSS animations
caret: 'hide', // No blinking cursor
},
},
});Start strict (low tolerance) and loosen only when you see false positives. It is better to investigate 5 false failures than to miss 1 real bug because your threshold was too high.
Do not use maxDiffPixels and maxDiffPixelRatio together in the same assertion. They conflict. Pick one approach and stick with it. maxDiffPixelRatio is usually the safer choice for pages of varying sizes.
Key Point: Use maxDiffPixelRatio for percentage tolerance, threshold for color sensitivity, animations: "disabled" and caret: "hide" to eliminate flakiness.