ScreenshotNeo

BlogHow-to

How to Detect CSS Changes with Automated Website Screenshots

Catch unintended visual regressions by comparing controlled Playwright screenshots with reviewed baselines in CI. Learn how to stabilize captures, review diffs, and fix common failures.

By the ScreenshotNeo team4 October 202610 min read

To detect CSS changes automatically, capture the same page or component state in a controlled browser environment and compare the new screenshot with a reviewed baseline. Playwright Test includes screenshot assertions through expect(page).toHaveScreenshot(). Run those assertions in CI so a visual difference is reported with actual, expected, and diff images for review.

A screenshot comparison catches rendered changes that a functional assertion may miss: a shifted layout, changed typography, altered color, or missing element. It does not decide whether a difference is a bug. A person should review the diff and update the baseline only when the design change is intentional.

1. Set up a Playwright visual test

In an existing Playwright Test project, add a test like this. It assumes your app is running at http://localhost:3000.

import { test, expect } from '@playwright/test';

test('homepage visual appearance', async ({ page }) => {
  await page.goto('http://localhost:3000');
  await expect(page).toHaveScreenshot('homepage.png');
});

For a new screenshot assertion, the initial run creates the expected image. Inspect that image and commit it as the baseline. Later runs compare the rendered page against that stored reference and fail the assertion when the difference exceeds the configured tolerance.

Install Playwright Test if the project does not already use it, then install the browser binaries required by your project using the commands in the Playwright visual comparisons guide. Keep the Playwright package and installed browser versions consistent across local development and CI.

Make the app available before capture

Configure Playwright to start a development or preview server automatically. For example, add a webServer entry to playwright.config.ts:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  use: {
    baseURL: 'http://127.0.0.1:3000',
    browserName: 'chromium',
  },
  webServer: {
    command: 'npm run dev -- --host 127.0.0.1',
    url: 'http://127.0.0.1:3000',
    reuseExistingServer: !process.env.CI,
  },
});

Use the start command and readiness URL that match your app. If the server needs a build step, run that before the Playwright command or configure the server command to build and start the app. In CI, make sure the test process can reach the server on the configured address and port.

2. Choose useful pages, states, and viewports

Start with pages and states where a visual regression would matter to users. A focused assertion is easier to diagnose than a single screenshot that covers a whole site.

  • Cover high-value routes such as the home page, sign-in flow, and key product pages.
  • Capture important component states: validation errors, expanded navigation, empty states, or a selected tab.
  • Include representative responsive widths. A desktop baseline will not catch every mobile layout regression.
  • Give screenshots stable, descriptive names so the failing route or state is clear in test output.

For example, test a mobile viewport with a separate project or test configuration. Keep viewport and device scale settings stable: a change in either can alter the image even when the CSS is unchanged.

import { test, expect } from '@playwright/test';

test.use({ viewport: { width: 390, height: 844 } });

test('mobile navigation', async ({ page }) => {
  await page.goto('/');
  await page.getByRole('button', { name: 'Menu' }).click();
  await expect(page).toHaveScreenshot('mobile-navigation-open.png');
});

Use accessible roles and labels to reach the state when possible. If the control labels differ in your app, change the locator to match. A screenshot taken before the intended state is ready may be stable but still test the wrong thing.

3. Make captures deterministic

Visual testing works best when the page state and rendering environment are repeatable. Playwright notes that rendering can vary with the host operating system, browser version, settings, hardware, power source, headless mode, and other factors. Generate and compare baselines in the same environment, ideally using the same CI image and browser version.

Wait for the content you care about

Wait for a meaningful page condition instead of relying on a fixed sleep. For a route that fetches data, wait for the expected heading or content before the screenshot:

await page.goto('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await expect(page).toHaveScreenshot('dashboard.png');

If content loads from an API, use stable test data or a controlled test environment when available. Unstable data can create diffs unrelated to CSS, such as changing names, timestamps, or counts.

Control animations, time, and volatile regions

Playwright’s screenshot assertion waits for two consecutive screenshots to match before comparing. You can also use screenshot options such as animations, caret, and stylePath to reduce capture noise. A screenshot stylesheet can hide or neutralize content that changes on every run. Apply exclusions narrowly: masking a large region can also hide a real layout or styling regression.

import { test, expect } from '@playwright/test';

test('product page visual appearance', async ({ page }) => {
  await page.goto('/products/example');
  await expect(page).toHaveScreenshot('product-page.png', {
    animations: 'disabled',
    caret: 'hide',
    stylePath: './tests/visual-stability.css',
  });
});

An example tests/visual-stability.css file might hide a timestamp or replace a blinking cursor for the capture:

/* Limit these rules to content that is intentionally volatile. */
.test-only-live-timestamp {
  visibility: hidden !important;
}

.test-only-blinking-cursor {
  animation: none !important;
}

The stylesheet is applied for screenshot capture. Do not use it to hide the component or layout under test. For time-dependent pages, freeze the clock in your test or provide deterministic fixture data where practical.

Use tolerances with care

toHaveScreenshot() supports options including maxDiffPixels, which sets a bound on the number of differing pixels. The API also documents percentage-based thresholds and pixel comparison settings. See the current Playwright PageAssertions documentation for the supported options and defaults.

await expect(page).toHaveScreenshot('homepage.png', {
  maxDiffPixels: 100,
});

The number above is an example policy value, not a universal recommendation. Start with strict comparisons in a stable environment. If small rendering noise remains, investigate its source before raising the threshold. A permissive threshold can conceal a small but important change, especially in an icon, label, or narrow component.

4. Create and review baselines

  1. Run the visual test for the first time in the environment you intend to use for comparisons.
  2. Open the generated expected screenshots and confirm they show the intended page, state, viewport, and content.
  3. Commit the reviewed baseline images alongside the test code.
  4. Run the tests on subsequent changes and inspect actual, expected, and diff images for failures.
  5. Update the baseline only after a reviewer accepts the visual change.

When an intentional design change updates the expected image, review the new image as part of the code review. Do not accept snapshot updates automatically just to make a failing CI job pass. That would replace the reference without confirming whether the new appearance is correct.

5. Run visual checks in CI

Run the same Playwright command in CI that you use locally, with the same browser build and operating system used to create the baselines. A typical project can expose a script such as test:e2e and run it after installing dependencies and browser binaries.

{
  "scripts": {
    "test:e2e": "playwright test"
  }
}

CI should retain Playwright’s test report and failure artifacts so a reviewer can examine screenshots and diffs. Ensure your repository includes the expected baseline files, and avoid generating a new baseline in the normal CI job: CI should compare against the reviewed reference, not silently redefine it.

For pull requests, make visual failures actionable: identify the failing test, attach the diff or report, and let the author determine whether the change is a regression or an approved design update. If a test passes locally but fails in CI, first compare browser versions, OS, fonts, viewport, device scale factor, and runtime state.

6. Troubleshoot common visual test failures

Symptom Likely cause Fix
Many pixels differ on every run Different browser, OS, fonts, device scale, or rendering environment Create and compare baselines in a consistent environment; pin the browser version and viewport.
Only timestamps, avatars, or rotating content differ Dynamic data or animation is present during capture Use fixture data, freeze time, disable animation, or narrowly mask the volatile element.
The screenshot is blank or incomplete The app did not load, the server was not ready, or the capture happened before the relevant content appeared Check server startup and navigation errors; wait for a specific visible element before capturing.
A page is captured in the wrong state The interaction did not happen or the locator selected the wrong control Assert the target state before the screenshot, such as a dialog becoming visible or a menu opening.
The baseline update produces a huge diff A broad CSS change, wrong route, changed viewport, or incorrect baseline environment Inspect actual, expected, and diff images; verify URL, viewport, browser, and app data before accepting.
CI fails while local runs pass Environment mismatch, missing font or browser dependency, or nondeterministic data Match CI’s OS and browser locally where possible; inspect CI artifacts and make test data deterministic.
Tests pass despite an obvious small defect The configured pixel tolerance is too high or the affected region is masked Lower the tolerance or narrow the excluded region, then add a focused assertion for the component.
The test times out waiting for the page The app or a required request is slow, unavailable, or waiting on a condition that never occurs Inspect server logs and network failures; wait for the correct readiness condition and fix the underlying request where possible.

When investigating a diff, check whether the change is localized or global. A global shift often points to a viewport, font, browser, or page-state difference. A localized change is more likely to be a component regression or a legitimate design update.

7. Performance, reliability, and maintenance

  • Keep the suite focused. Begin with critical routes and states, then add coverage where regressions would be costly. Each extra viewport and state adds capture and review work.
  • Stabilize before adding retries. Retries can help identify intermittent failures, but they do not make a nondeterministic screenshot meaningful. Fix unstable data, timing, or environment differences first.
  • Use focused screenshots. A component-level or state-specific capture can make a diff easier to diagnose than a very long page screenshot. Use full-page captures when below-the-fold layout is part of the risk.
  • Keep baselines reviewable. Store them with the code and make intentional updates visible in pull requests. Remove obsolete snapshots when tests or routes are retired.
  • Budget for review. The main ongoing cost of a local Playwright approach is maintaining stable test environments and reviewing baseline changes. Hosted services can add centralized review workflows, but their current plan limits and pricing should be checked directly before adoption.

Playwright Test is a practical default when a team already uses Playwright and wants local screenshot baselines stored with tests. Percy documents Playwright capture, custom CSS injection, ignored regions, and routing existing screenshot assertions through Percy in its Playwright integration. Chromatic documents a Playwright visual testing integration and a GitHub Actions workflow. Compare these approaches by where reviewers inspect diffs, baseline ownership, CI fit, dynamic-region controls, and the browser and viewport coverage your team needs. Verify current vendor plans before deciding based on price or limits.

Or skip the browser setup

If you need a screenshot of a live page without maintaining a browser capture script, ScreenshotNeo is a website screenshot API and MCP server. It returns a screenshot or PDF from one GET request. It is useful for capturing current appearance or gathering visual evidence; use a reviewed baseline comparison such as the Playwright workflow above when you need automated CSS regression detection.

For example, save a WebP capture of the Stripe homepage with cURL:

curl -G "https://api.screenshotneo.com/v1/shot" \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://stripe.com \
  -o shot.webp

Python:

import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)

Node.js:

const q = new URLSearchParams({
  access_key: 'YOUR_API_KEY',
  url: 'https://stripe.com',
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
const bytes = new Uint8Array(await res.arrayBuffer());
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', bytes));

See the ScreenshotNeo API documentation for request options and setup. Cookie banners, popups, and chat widgets are removed before the shot; bot checks, blank pages, and failed loads are never billed. Its MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots a month with no card, and paid plans start at $5 for 3,000.

Sign up for 1,000 free screenshots a month, with no card required.

Frequently asked questions

Can screenshot tests tell whether a CSS change was intentional?

No. They detect a rendered difference and provide images for review. The team decides whether that difference is an accepted design change or a regression.

Should I compare screenshots on every operating system?

Begin with the environment your CI uses consistently. Add other browser or operating-system coverage when cross-environment rendering is an explicit compatibility requirement.

Can I use a screenshot API by itself for regression detection?

An API can capture a page, but regression detection also needs a stored reference, an image comparison, and a review process. The API call alone does not determine whether the current page differs from an accepted baseline.

When should I update a snapshot?

After reviewing the visual change and confirming it is intended. Include the updated baseline in the same change so reviewers can see what changed.