Visual regression in your CI pipeline

Catch visual changes before they reach production. Start free with snapdrift, or connect Snap's hosted provider for hosted baselines, pinned rendering, and a review dashboard.

Free & open sourceGitHub Actions readyHosted provider available

Overview

snapdrift is a free, open-source visual-regression runner for GitHub Actions: a config file and two workflow jobs capture each route on a PR, diff it against a baseline, and post the result as a PR comment. Snap is the hosted provider for this workflow: connect provider: "snap" (β‰₯ v0.6.0) for hosted baselines, Lambda-rendered captures, and a review dashboard.

How it works

Every pull request runs the same four-step loop:

1
Open a PR
Your workflow triggers on pull_request
2
Capture
snapdrift screenshots each configured route
3
Diff vs baseline
Pixel diff against the stored baseline
4
PR comment
Results posted back to the pull request

Start free β€” snapdrift local mode

No Snap account needed. snapdrift starts Playwright inside your GitHub Actions runner, captures each configured route against your running app, and diffs the result against a stored baseline. Baselines live as workflow artifacts β€” free for open-source and small teams.

Step 1 β€” Add a config file

Create .github/snapdrift.json in your repo. baseUrl is the address of your app once it's started in the CI runner:

json
{
  "baselineArtifactName": "my-app-snapdrift-baseline",
  "baseUrl": "http://127.0.0.1:3000",
  "routes": [
    { "id": "home-desktop", "path": "/",         "viewport": "desktop" },
    { "id": "home-mobile",  "path": "/",         "viewport": "mobile"  },
    { "id": "dashboard",    "path": "/dashboard","viewport": "desktop" }
  ],
  "diff": { "threshold": 0.01, "mode": "report-only" }
}

Step 2 β€” Add two workflow jobs

One job publishes a baseline when you merge to main. The other runs the diff on every pull request. You own app startup β€” snapdrift takes over once the app is reachable.

yaml
name: Visual Regression
on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read
  actions: read
  issues: write
  pull-requests: write

jobs:
  baseline:
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: npm
      - run: npm ci && npm run build

      - name: Start app
        run: |
          npm start &
          for i in $(seq 1 45); do
            curl -sf http://127.0.0.1:3000 && break || sleep 1
          done

      - name: SnapDrift Baseline
        uses: ranacseruet/snapdrift/actions/baseline@v0.2.1
        with:
          repo-config-path: .github/snapdrift.json

  pr-diff:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: npm
      - run: npm ci && npm run build

      - name: Start app
        run: |
          npm start &
          for i in $(seq 1 45); do
            curl -sf http://127.0.0.1:3000 && break || sleep 1
          done

      - name: SnapDrift Report
        uses: ranacseruet/snapdrift/actions/pr-diff@v0.2.1
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          repo-config-path: .github/snapdrift.json

snapdrift installs Playwright, captures each route against your running app, diffs against the latest baseline artifact, and posts results to the PR.

Drift enforcement modes

Set diff.mode in your config to control what happens when drift is detected:

ModeBehaviorWhen to use
report-onlyAlways passes; posts results for visibilityStart here while baselines settle
fail-on-changesFails the check when any route exceeds the thresholdDay-to-day once the signal is stable
fail-on-incompleteFails on missing captures, dimension shifts, or errorsCatch broken or partial captures
strictFails on any drift signal whatsoeverLock things down before a release

Hosted Snap provider

When GH artifact retention (~90 days) becomes a problem or rendering inside the CI runner is too slow, Snap is the hosted upgrade, available now. snapdrift's provider: "snap" integration (β‰₯ v0.6.0) switches you over with one config change and a Snap API key.

Snap is a drop-in backend for snapdrift. provider: snap hands rendering to Snap's Lambda fleet and moves baselines to hosted storage while retaining the same route and diff configuration.

Available today: the hosted provider: snap path (hosted baselines, Lambda rendering, review dashboard), snapdrift local mode, and Snap's authenticated POST /v1/screenshots for single-shot CI captures.

Hosted-provider capabilities

Render off your CI runner

Snap's Lambda renderer captures your preview deploy URL. No Playwright install, no app startup in CI, no runner minutes burned on rendering.

Durable baseline store

The provider moves baselines from GitHub artifacts into S3-backed hosted storage, so retention isn't capped by GitHub's artifact window.

Pinned, deterministic rendering

Snap's renderer pins Chromium and its font environment to reduce drift caused by CI runner image updates.

Triage dashboard

Review diffs, comment, approve and publish new baselines, or reject runs. Available at /dashboard/visual.

You can call Snap's screenshot API directly from CI via POST /v1/screenshots for single-shot captures. See the API reference.

Get started with hosted Visual CI

The hosted provider is available now via the dashboard. This is the full zero-to-reviewed-run path β€” hosted baselines, Lambda-rendered captures, and the review dashboard. Everything below uses snapdrift @v0.6.0.

Step 0 β€” Create your free Snap account

The hosted provider needs a Snap account, and every step below assumes you can reach the dashboard. Snap accounts and plans are managed through i2dev Platform: choose Get started free (the Freemium tier β€” no credit card) and you'll land on the Snap dashboard. Freemium's 1,000 renders/month is enough to run the full hosted flow below. Already signed in? Skip to Step 1.

Step 1 β€” Create a project

Open /dashboard/visual and choose Create project. Give it a name and slug; optionally set Repository (owner/name) and a default branch so runs link back to your repo. The new project page shows its ID β€” prj_… β€” which you reference from your config in Step 4.

Step 2 β€” Issue a scoped API key

On the project page, open the Visual API keys panel, tick the scopes your CI needs, add an optional label, and choose Issue key. The key (sk_…) is shown once β€” copy it now.

ScopeGrantsRole to grant
visual:captureUpload runs and captures from CIMember+
visual:readRead project and run data (poll run status, build the PR comment)Member+
visual:baseline:publishAccept and publish baselines (used by the baseline job on main)Owner / admin
visual:exportExport run and baseline data (needed only to migrate baselines back out)Owner / admin

For the CI key, grant visual:capture + visual:read + visual:baseline:publish β€” the baseline job on main publishes baselines, so it needs the publish scope. Only an owner/admin can grant visual:baseline:publish and visual:export; members can grant only visual:capture and visual:read. Add visual:export only if you also want to migrate baselines back out (Step β€œNo lock-in”).

Step 3 β€” Add the key as a repo secret

In your GitHub repo, go to Settings β†’ Secrets and variables β†’ Actions β†’ New repository secret and add the key as SNAP_API_KEY. Your config references it by name (apiKeyEnv) so the raw key never lands in the repo.

Step 4 β€” Write .github/snapdrift.json

This example runs in hybrid local-capture mode β€” the common CI case. Because baseUrl is a localhost address, snapdrift starts Playwright on the runner to render your preview server, then uploads the screenshots to Snap. Point baseUrl at a public preview URL instead and Snap's renderer captures it server-side, with no Playwright in the runner.

json
{
  "provider": "snap",
  "snap": {
    "apiKeyEnv": "SNAP_API_KEY",
    "projectId": "prj_your_project_id",
    "onUnavailable": "fail"
  },
  "baselineArtifactName": "my-app-visual",
  "workingDirectory": ".",
  "baseUrl": "http://127.0.0.1:3000",
  "resultsFile": "qa-artifacts/snapdrift/current/results.json",
  "manifestFile": "qa-artifacts/snapdrift/current/manifest.json",
  "screenshotsRoot": "qa-artifacts/snapdrift/current",
  "routes": [
    { "id": "home-desktop",    "path": "/",        "viewport": "desktop" },
    { "id": "home-mobile",     "path": "/",        "viewport": "mobile"  },
    { "id": "pricing-desktop", "path": "/pricing", "viewport": "desktop" }
  ],
  "diff": { "threshold": 0.01, "mode": "report-only" }
}

Set projectId to the prj_… ID from your project page β€” Snap resolves projects by this generated ID only. Optional snap.apiUrl defaults to https://snap.i2dev.com. Start with diff.mode: "report-only" so runs post results without failing while baselines settle, then switch to "fail-on-changes" once the signal is trustworthy.

Step 5 β€” Add the workflow jobs

Two jobs, both pinned to @v0.6.0: baseline publishes to Snap on merges to main (pass upload-artifact: 'false' so the baseline lives on Snap, not as a GitHub artifact); pr-diff runs the diff on every PR. Each step exposes the key via env: SNAP_API_KEY. In hybrid mode you own app startup, exactly like local mode.

yaml
name: Visual Regression (Snap)
on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read
  actions: read
  issues: write
  pull-requests: write

jobs:
  baseline:
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: npm
      - run: npm ci && npm run build

      - name: Start app
        run: |
          npm start &
          for i in $(seq 1 45); do
            curl -sf http://127.0.0.1:3000 && break || sleep 1
          done

      - name: Publish Snap baseline
        uses: ranacseruet/snapdrift/actions/baseline@v0.6.0
        env:
          SNAP_API_KEY: ${{ secrets.SNAP_API_KEY }}
        with:
          repo-config-path: .github/snapdrift.json
          upload-artifact: 'false'

  pr-diff:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: npm
      - run: npm ci && npm run build

      - name: Start app
        run: |
          npm start &
          for i in $(seq 1 45); do
            curl -sf http://127.0.0.1:3000 && break || sleep 1
          done

      - name: Snap visual diff
        uses: ranacseruet/snapdrift/actions/pr-diff@v0.6.0
        env:
          SNAP_API_KEY: ${{ secrets.SNAP_API_KEY }}
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          repo-config-path: .github/snapdrift.json

Step 6 β€” First run & review in the dashboard

Merge to main once to publish the first baseline. From then on, every PR triggers pr-diff: snapdrift creates a run, submits one capture per route Γ— viewport, waits for Snap to render and diff each one, and posts a PR comment with a View in dashboard β†’ link. Open /dashboard/visual, pick the run, and use Approve & publish baseline, Reject, or Comment. Approving a run that errored with dimension_mismatch accepts the new layout as the baseline.

Troubleshooting hosted runs

When Snap can't be reached β€” snap.onUnavailable controls what happens on a 5xx or network failure (4xx errors never retry and never fall back):

ModeBehaviorWhen to use
failFail the action with a non-retryable error (default)You want visual checks to be a hard gate
warn-and-skipLog a warning, write a skipped summary, exit 0Snap outages should never block merges
fallback-localLog a warning and finish the run with the local providerYou keep local baselines as a backup path

Capture errors β€” the run page shows a human-readable reason for each capture. The ones you're most likely to hit:

ReasonMeaningWhat to do
dimension_mismatchThe new screenshot's size differs from the baseline (a layout change)If intentional, approve the run in the dashboard β€” that accepts the new layout as the baseline.
render_timeoutThe page took too long to renderCheck that your preview URL is reachable and the route settles; slow third-party scripts are a common cause.
render_blockedThe page blocked automated rendering (bot / anti-automation protection)Point the run at a preview environment without a bot wall, or allowlist Snap's renderer.
No baseline yetThe project has no accepted baseline for this route (a 404 that Snap treats as first-run)Merge to main once to publish a baseline, or keep diff.mode on report-only until one exists.

Auth & project errors. A 401 means the SNAP_API_KEY secret is missing or revoked β€” re-issue the key and update the secret. A 403 means the key is missing a scope the job needs (for example the baseline job needs visual:baseline:publish). project not found means projectId doesn't match a project on Snap β€” copy the prj_… ID from the dashboard, or check that "auto" resolves to a project you own. Limits are generous (per-key rate and concurrency caps that most CI never touches, and the run-status polling and result reads a CI run makes don't draw down the monthly screenshot quota β€” only each rendered capture does) β€” contact us if you expect to push past them.

Your baselines aren't locked in

Moving in and out of hosted storage is a single CLI command in either direction, so adopting the hosted provider is reversible.

Import local baselines into Snap β€” if you already run snapdrift in local mode, publish your established baselines to the hosted store once:

bash
snapdrift migrate-baselines --to snap

Export them back out β€” download the project's accepted baselines into a local baseline directory (useful for reproducible local debugging, or to leave the hosted provider entirely):

bash
snapdrift migrate-baselines --to local --from snap

The export direction downloads the project's export archive, so the key needs the visual:export scope. Baselines that Snap rendered server-side need --accept-cross-engine to import into a local (Playwright) engine, since the rendering engines differ.

snapdrift vs Snap

snapdrift

Free Β· OSS
  • βœ“Capture + diff + PR comment
  • βœ“Local CLI for dev-time checks
  • βœ“Route scoping by changed files
  • βœ“Four enforcement modes
  • βœ“Baselines in GH artifacts (~90d)
  • βœ—App must run in the CI runner
  • βœ—Playwright installs on every run
  • βœ—Browser version drifts with runner image
  • βœ—No cross-CI (GitHub Actions only)
  • βœ—No triage dashboard

Snap

Available
  • β€’Capture + diff + PR comment
  • β€’Local CLI for dev-time checks
  • β€’Route scoping by changed files
  • β€’Four enforcement modes
  • β€’S3-backed hosted baselines
  • β€’Captures your preview deploy β€” no app startup in CI
  • β€’Lambda render β€” no Playwright in runner
  • β€’Pinned Chromium + fonts
  • β€’Provider path designed for multiple CI systems
  • β€’Visual review dashboard

Ready to add visual testing to your CI?

snapdrift is free and open source. Snap's hosted provider is available now for hosted baselines, Lambda rendering, and the review dashboard.