Here is the number one reason visual tests fail: dynamic content. A timestamp shows "5:32 PM" in one run and "5:33 PM" in the next. A live balance updates every second. A random ad loads in the sidebar. Different content every time means different pixels every time. Your test fails even though nothing is actually broken.
mask takes an array of locators. Playwright covers those elements with a solid color box before taking the screenshot. The dynamic content is hidden. The rest of the page is compared normally.
import { test, expect } from '@playwright/test';
test('dashboard with masked dynamic content', async ({ page }) => {
await page.goto('/banking');
await expect(page).toHaveScreenshot('dashboard-masked.png', {
mask: [
page.getByTestId('current-date'),
page.getByTestId('session-id'),
page.getByTestId('live-balance'),
page.getByTestId('last-login-time'),
page.locator('.notification-badge'),
],
});
});test('mask with custom color', async ({ page }) => {
await page.goto('/banking');
// Default mask color is #FF00FF (magenta). You can change it.
await expect(page).toHaveScreenshot('dashboard-custom-mask.png', {
mask: [page.getByTestId('live-balance')],
maskColor: '#000000', // black mask -- blends with dark themes
});
});Sometimes masking is not enough. You need to hide elements, freeze animations, or override styles before the screenshot. stylePath injects a CSS file into the page for the screenshot only.
/* Hide dynamic elements */
[data-testid="current-date"],
[data-testid="live-balance"],
.notification-badge,
.chat-widget {
visibility: hidden !important;
}
/* Freeze all animations and transitions */
*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
}
/* Hide scrollbars (different across OS) */
::-webkit-scrollbar {
display: none !important;
}test('screenshot with injected CSS', async ({ page }) => {
await page.goto('/banking');
await expect(page).toHaveScreenshot('dashboard-clean.png', {
stylePath: 'tests/visual/screenshot.css',
});
});If your page shows a clock or timestamp, you can freeze JavaScript time so it always shows the same value.
test('freeze time for consistent screenshots', async ({ page }) => {
// Set a fixed date before navigating
await page.clock.setFixedTime(new Date('2025-01-15T10:30:00'));
await page.goto('/banking');
// The page always shows "Jan 15, 2025 10:30 AM"
// No need to mask the date element
await expect(page).toHaveScreenshot('dashboard-frozen-time.png');
});test('disable all animations before screenshot', async ({ page }) => {
await page.goto('/banking');
// Inject CSS to kill all animations
await page.addStyleTag({
content: `
*, *::before, *::after {
animation: none !important;
transition: none !important;
}
`,
});
// Wait for any in-progress animations to settle
await page.waitForTimeout(500);
await expect(page).toHaveScreenshot('no-animations.png');
});Combine strategies. Use mask for content that changes (dates, balances). Use stylePath for layout elements you want hidden (chat widgets, cookie banners). Use clock.setFixedTime for time-based content. Layer them for rock-solid screenshots.
Q: How do you handle dynamic content in visual tests?
A: Three strategies. First, mask dynamic elements using the mask option in toHaveScreenshot() -- this covers them with a solid color box. Second, inject CSS via stylePath to hide or freeze elements. Third, use page.clock.setFixedTime() to freeze JavaScript time for timestamps and clocks. For animations, Playwright disables CSS animations by default, but you may need to handle JavaScript animations separately with addStyleTag or waitForTimeout.
Key Point: Mask dynamic content with the mask option. Inject CSS with stylePath. Freeze time with clock.setFixedTime(). Layer these strategies to eliminate flaky visual tests.