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.
Install
Section titled “Install”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.
Configure
Section titled “Configure”Add the pxdiff plugin to your Vitest config alongside the browser provider:
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"] }}With React
Section titled “With React”If you’re testing React components, add @vitejs/plugin-react and use vitest-browser-react for rendering:
npm install --save-dev @vitejs/plugin-react vitest-browser-reactimport { 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" }], }, },});With Vue
Section titled “With Vue”npm install --save-dev @vitejs/plugin-vue vitest-browser-vueimport { 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" }], }, },});With Tailwind CSS
Section titled “With Tailwind CSS”If your components use Tailwind, add the Tailwind Vite plugin so styles are available in browser mode:
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" }], }, },});Write Tests
Section titled “Write Tests”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.
Testing Component Variants
Section titled “Testing Component Variants”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.
Ignoring Dynamic Content
Section titled “Ignoring Dynamic Content”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.
Tolerance Threshold
Section titled “Tolerance Threshold”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});How It Works
Section titled “How It Works”When toMatchPxdiff runs:
- 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.
- Takes a screenshot — uses the browser provider’s native screenshot API (Playwright’s
page.screenshot()under the hood). - Uploads and diffs — sends the screenshot to the pxdiff API, which compares it against the current baseline using pixelmatch and returns the result.
- 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.
Local Development
Section titled “Local Development”One-time setup — authenticate and link your project:
pxdiff loginpxdiff project set myorg/myprojectThen run your tests through pxdiff local:
pxdiff local -- npx vitest runpxdiff 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:
pxdiff local -- npx vitestScreenshots 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.
name: Visual Regressionon: 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.
Auto-approve on main
Section titled “Auto-approve on main”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.
Best Practices
Section titled “Best Practices”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.
Cross-platform consistency
Section titled “Cross-platform consistency”If your screenshots differ between macOS and Linux due to font rendering, use --docker to run tests in a consistent container:
pxdiff run --docker -- npx vitest runpxdiff local --docker -- npx vitest runSee the Docker guide for details.
See the Vitest plugin reference for all plugin options and matcher options.