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:
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:
{
"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.
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.jsonsnapdrift 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:
| Mode | Behavior | When to use |
|---|---|---|
report-only | Always passes; posts results for visibility | Start here while baselines settle |
fail-on-changes | Fails the check when any route exceeds the threshold | Day-to-day once the signal is stable |
fail-on-incomplete | Fails on missing captures, dimension shifts, or errors | Catch broken or partial captures |
strict | Fails on any drift signal whatsoever | Lock 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.
| Scope | Grants | Role to grant |
|---|---|---|
visual:capture | Upload runs and captures from CI | Member+ |
visual:read | Read project and run data (poll run status, build the PR comment) | Member+ |
visual:baseline:publish | Accept and publish baselines (used by the baseline job on main) | Owner / admin |
visual:export | Export 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.
{
"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.
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.jsonStep 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):
| Mode | Behavior | When to use |
|---|---|---|
fail | Fail the action with a non-retryable error (default) | You want visual checks to be a hard gate |
warn-and-skip | Log a warning, write a skipped summary, exit 0 | Snap outages should never block merges |
fallback-local | Log a warning and finish the run with the local provider | You 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:
| Reason | Meaning | What to do |
|---|---|---|
dimension_mismatch | The 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_timeout | The page took too long to render | Check that your preview URL is reachable and the route settles; slow third-party scripts are a common cause. |
render_blocked | The 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 yet | The 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:
snapdrift migrate-baselines --to snapExport 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):
snapdrift migrate-baselines --to local --from snapThe 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