Skip to content

Visual Regression Testing with Vitest Browser Mode

Vitest Browser Mode runs your component tests in a real browser instead of jsdom. This means you can take pixel-accurate screenshots of rendered components and diff them against baselines — true visual regression testing as part of your existing test suite.

The @pxdiff/vitest plugin adds a toMatchPxdiff matcher that takes a screenshot, uploads it to pxdiff, and returns the diff result inline. No global setup files, no fixture boilerplate, no separate capture step.

Terminal window
npm install --save-dev @pxdiff/vitest @vitest/browser-playwright

@vitest/browser-playwright provides the Chromium browser that Vitest Browser Mode runs in. You can also use @vitest/browser-webdriverio if you prefer WebDriver.

Add the pxdiff plugin to your Vitest config alongside the browser provider:

vitest.config.ts
import { pxdiffPlugin } from "@pxdiff/vitest/plugin";
import { playwright } from "@vitest/browser-playwright";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [pxdiffPlugin()],
test: {
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: "chromium" }],
},
},
});

The plugin automatically registers the toMatchPxdiff matcher and the browser commands needed for screenshot upload — no manual setup files required.

For TypeScript, add the type augmentation to your tsconfig.json:

{
"compilerOptions": {
"types": ["@pxdiff/vitest/types"]
}
}

If you’re testing React components, add @vitejs/plugin-react and use vitest-browser-react for rendering:

Terminal window
npm install --save-dev @vitejs/plugin-react vitest-browser-react
vitest.config.ts
import { pxdiffPlugin } from "@pxdiff/vitest/plugin";
import react from "@vitejs/plugin-react";
import { playwright } from "@vitest/browser-playwright";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react(), pxdiffPlugin()],
test: {
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: "chromium" }],
},
},
});
Terminal window
npm install --save-dev @vitejs/plugin-vue vitest-browser-vue
vitest.config.ts
import { pxdiffPlugin } from "@pxdiff/vitest/plugin";
import vue from "@vitejs/plugin-vue";
import { playwright } from "@vitest/browser-playwright";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [vue(), pxdiffPlugin()],
test: {
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: "chromium" }],
},
},
});

If your components use Tailwind, add the Tailwind Vite plugin so styles are available in browser mode:

vitest.config.ts
import { pxdiffPlugin } from "@pxdiff/vitest/plugin";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { playwright } from "@vitest/browser-playwright";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [tailwindcss(), react(), pxdiffPlugin()],
test: {
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: "chromium" }],
},
},
});

Use toMatchPxdiff on page (full viewport) or any element locator:

import { expect, it } from "vitest";
import { page } from "vitest/browser";
import { render } from "vitest-browser-react";
it("button visual regression", async () => {
await render(<Button variant="primary">Click me</Button>);
await expect(page).toMatchPxdiff("button-primary");
});

Each toMatchPxdiff call takes a screenshot, uploads it to pxdiff, and compares it against the existing baseline. If there’s no baseline yet, the screenshot becomes the new baseline automatically.

Test multiple visual states in a single file:

import { describe, expect, it } from "vitest";
import { page } from "vitest/browser";
import { render } from "vitest-browser-react";
describe("Button", () => {
it("primary", async () => {
await render(<Button variant="primary">Submit</Button>);
await expect(page).toMatchPxdiff("button-primary", {
path: ["components", "Button"],
});
});
it("secondary", async () => {
await render(<Button variant="secondary">Cancel</Button>);
await expect(page).toMatchPxdiff("button-secondary", {
path: ["components", "Button"],
});
});
it("disabled", async () => {
await render(<Button disabled>Disabled</Button>);
await expect(page).toMatchPxdiff("button-disabled", {
path: ["components", "Button"],
});
});
});

The path option organizes screenshots into a tree hierarchy in the pxdiff review UI — useful when you have dozens or hundreds of snapshots.

Hide elements that change between runs (timestamps, avatars, ads) to avoid false positives:

await expect(page).toMatchPxdiff("dashboard", {
ignoreSelectors: [".timestamp", ".random-avatar"],
});

You can also mark elements directly in your component markup:

<span data-pxdiff="ignore">Updated 3 minutes ago</span>

Elements with data-pxdiff="ignore" are hidden automatically in every screenshot — no need to pass ignoreSelectors.

For components with minor rendering differences across runs (charts, maps, canvas elements), set a pixel diff tolerance:

await expect(page).toMatchPxdiff("analytics-chart", {
maxDiffPixelRatio: 0.01, // Allow up to 1% pixel difference
});

When toMatchPxdiff runs:

  1. Stabilizes the DOM — freezes CSS animations and transitions, blurs the active element, waits for fonts to load, and moves the cursor off-screen to prevent hover-state flicker.
  2. Takes a screenshot — uses the browser provider’s native screenshot API (Playwright’s page.screenshot() under the hood).
  3. Uploads and diffs — sends the screenshot to the pxdiff API, which compares it against the current baseline using pixelmatch and returns the result.
  4. Asserts — passes or fails the test based on the diff result and your blocking configuration.

The entire flow happens inline during the test — no separate capture step, no post-processing script.

One-time setup — authenticate and link your project:

Terminal window
pxdiff login
pxdiff project set myorg/myproject

Then run your tests through pxdiff local:

Terminal window
pxdiff local -- npx vitest run

pxdiff local sets up the environment for local diffing. Screenshots are compared against your project’s baselines and results are shown in the pxdiff review UI. Approvals and rejections you make locally are scoped to your machine — they don’t affect CI baselines.

During development you can also run Vitest in watch mode:

Terminal window
pxdiff local -- npx vitest

Screenshots are uploaded on every test re-run, so you get live visual feedback as you iterate on components.

Create an API key for your project and set it as a repository secret.

.github/workflows/visual.yml
name: Visual Regression
on: pull_request
jobs:
visual:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- name: Visual Regression
run: pxdiff run -- npx vitest run
env:
PXDIFF_API_KEY: ${{ secrets.PXDIFF_API_KEY }}

pxdiff run creates a GitHub check run, groups all screenshots into one session, and reports results back to the PR. When screenshots change, the check links directly to the pxdiff review UI where your team can approve or reject the changes.

To automatically accept visual changes that land on your main branch (so they become the new baselines), set autoApprove in the plugin config:

pxdiffPlugin({
autoApprove: !!process.env.CI && !process.env.GITHUB_HEAD_REF,
})

GITHUB_HEAD_REF is only set on pull request events — so autoApprove activates only on direct pushes to main.

Name snapshots descriptively. Snapshot names are permanent identifiers — renaming one creates a new baseline and orphans the old one. Use names like "button-primary-hover" not "test-1".

Use path for organization. Group related screenshots with path: ["components", "Button"]. This makes the review UI navigable when you have many snapshots.

One assertion per visual state. Don’t combine multiple visual states in one screenshot. Test the default state, hover state, and error state as separate snapshots for clear diffs.

Keep screenshots deterministic. Hide dynamic content with ignoreSelectors or data-pxdiff="ignore". Seed random data. Use fixed dates in tests.

Set blocking: false for non-critical visuals. If a screenshot changing shouldn’t fail your PR, set blocking: false at the plugin or matcher level. Changes are still tracked — they just don’t block merging.

If your screenshots differ between macOS and Linux due to font rendering, use --docker to run tests in a consistent container:

Terminal window
pxdiff run --docker -- npx vitest run
pxdiff local --docker -- npx vitest run

See the Docker guide for details.

See the Vitest plugin reference for all plugin options and matcher options.