description: Make Playwright VR captures deterministic with animation disabling, font waits, masking, and a global screenshot.css.
tldr: Add document.fonts.ready, animations: 'disabled', and a stylePath injecting screenshot.css to eliminate the three most common sources of VR flake: font swap, CSS animation frames, and dynamic content regions.
Stability Controls
When to Use
Use this when screenshots produce inconsistent results across runs due to animations, fonts, dynamic content, or scroll state.
Pattern
Animations and caret (defaults — verify they are on)
expect: {
toHaveScreenshot: { animations: 'disabled', caret: 'hide' },
}
Wait for fonts
await page.evaluate(() => document.fonts.ready);
Without this, the first screenshot captures the fallback font; the second captures the web font — baselines drift.
Wait for network idle
await page.goto('/', { waitUntil: 'networkidle' });
// or after load:
await page.waitForLoadState('networkidle');
Caveat: hangs on long-poll/SSE connections. Use 'domcontentloaded' + explicit waitForResponse in those cases.
Mask volatile regions
await expect(page).toHaveScreenshot({
mask: [
page.locator('[data-testid="last-updated"]'),
page.locator('.user-avatar'),
page.locator('iframe'),
],
});
Masks paint solid #FF00FF over the bounding box. Content inside cannot cause diffs.
stylePath — global stabilization CSS
tests/playwright/screenshot.css:
*, *::before, *::after {
animation: none !important;
transition: none !important;
caret-color: transparent !important;
}
iframe { visibility: hidden; }
.has-dynamic-counter { visibility: hidden; }
[data-vrt-mask] { visibility: hidden; }
expect: { toHaveScreenshot: { stylePath: './screenshot.css' } }
Pierces Shadow DOM and inner frames — works for SDC components and Olivero embedded content.
Scroll position normalization
await page.evaluate(() => window.scrollTo(0, 0));
// For element shots:
await locator.scrollIntoViewIfNeeded();
Lazy images / video posters
await page.evaluate(() =>
document.querySelectorAll('img[loading="lazy"]')
.forEach(img => (img.loading = 'eager'))
);
await page.waitForLoadState('networkidle');
Common Mistakes
- Wrong: Skipping
document.fonts.ready→ Right: first screenshot captures fallback font; baselines drift on every subsequent run - Wrong:
networkidleon long-poll/SSE pages → Right: hangs the test; use explicit waits - Wrong: Masks using CSS classes that are dynamically applied → Right: use stable attributes (
data-vrt-mask) so masking is consistent