@awhitty/snapper
v0.4.1
Published
Visual snapshot testing tool for React component libraries
Downloads
1,124
Readme
snapper
Visual snapshot testing for React component libraries.
Captures components as PNGs via Playwright, compares them against baselines, and gives you a workbench for reviewing and approving visual diffs. Integrates with Vite (or any Vite-compatible dev server — including Vite running alongside a Next.js app).
Quick start
1. Install
pnpm add -D @awhitty/snapper
# or
bun add -D @awhitty/snapper2. vite.config.ts (in your host project)
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { snapperPlugin } from "@awhitty/snapper/plugin";
export default defineConfig({
plugins: [
react(),
snapperPlugin({
// Host CSS for workbench preview + /__snapper/frame (chrome uses Shadow DOM)
styles: "./app/globals.css",
}),
],
});3. snapper.config.ts
import { defineConfig } from "@awhitty/snapper";
export default defineConfig({
snapshots: "components/**/*.snaps.tsx",
snapshotsDir: "./snapshots",
});4. Add scripts to package.json
{
"scripts": {
"snapper": "snapper",
"snapper:ci": "snapper --ci"
}
}5. Write a .snaps.tsx next to a component
// components/Button/Button.snaps.tsx
import { meta, snap } from "@awhitty/snapper/protocol";
import { Button } from "./Button";
export const Meta = meta("Components/Button");
export const Base = snap("Base", () => <Button>Click me</Button>);
export const Disabled = snap("Disabled", () => <Button disabled>—</Button>);6. Run it
pnpm snapper # watch mode + live workbench at http://localhost:5173/__snapper
pnpm snapper:ci # single run, exits non-zero if anything changed
pnpm snapper approve # approve pending diffs in snapshots/review/Architecture
┌──────────────────────────────────────────────────────────────────┐
│ / (one document: host CSS in <head> for real <html>/<body>) │
│ ┌────────────────────┐ ┌────────────────────────────────────┐ │
│ │ sidebar (Shadow │ │ preview panel (light DOM) │ │
│ │ DOM): preflight + │ │ host CSS + <div data-snapshot-root>│ │
│ │ workbench.css │ │ <Component /> │ │
│ └────────────────────┘ └────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘- Chrome shell (
/HTML) — host snapshot CSS loads in<head>so the preview matches production. The sidebar lives in a Shadow DOM with snapper's preflight +workbench.css, so host globals don't restyle the tree/search UI. - Frame page (
/__snapper/frame) — same snapshot renderer as the preview, but its own document with host CSS only (no snapper chrome). Used by Playwright captures and useful for debugging (__RENDER_SNAPSHOT__onwindow).
Interactive workbench selection updates the inline preview via React props;
Playwright still navigates to /__snapper/frame and calls
window.__RENDER_SNAPSHOT__(id) there.
Authoring
meta(name, options?)
Declares a group. name is the sidebar label (supports / for hierarchy).
export const Meta = meta("Components/Form", {
wrap: ({ children }) => <FormProvider>{children}</FormProvider>,
});wrap— optional file-scoped wrapper applied around every snapshot in the file (after the global wrap).
snap(name, component, options?)
Declares a single snapshot.
export const Base = snap("Base", () => <Button>Base</Button>, {
position: "center", // "top-left" | "top-center" | "top-right" | "center" | "bottom-*"
padding: 16, // number, string, or true for default
background: "#0b0b0b", // color, className, true (subtle), or false
});Global providers (snapper.config.ts wrap)
// snapper.config.ts
export default defineConfig({
snapshots: "components/**/*.snaps.tsx",
wrap: "./snapper.providers.tsx",
});// snapper.providers.tsx
import { Theme } from "@radix-ui/themes";
export default function Wrap({ children }: { children: React.ReactNode }) {
return <Theme>{children}</Theme>;
}Composition order when a snap renders: global wrap → file wrap (from meta) → <Component />.
Keep .snaps.tsx files clean
Only export meta and snap calls. Non-component runtime exports force
@vitejs/plugin-react to bail on Fast Refresh, which means every edit
triggers a full page reload. Move helpers to a sibling file.
Next.js hosts
Next.js uses webpack/turbopack at runtime, but snapper needs Vite. Your
Next.js project gets a vite.config.ts alongside next.config.js —
snapper's Vite server runs in parallel with Next's and is used only by
snapper.
Add the compat layer for common next/* imports:
import { snapperPlugin } from "@awhitty/snapper/plugin";
import { nextCompat } from "@awhitty/snapper/compat/next";
import { tsconfigPaths } from "@awhitty/snapper/compat/tsconfig-paths";
export default defineConfig({
plugins: [
tsconfigPaths(), // workspace aliases, path mappings
react(),
nextCompat(), // stubs next/image, next/link, next/font, etc.
snapperPlugin({ styles: "./app/globals.css" }),
],
});Covered out of the box:
next/image→<img>(supportssrc,alt,fill,width,height)next/link→<a>next/head→nullnext/script→nullnext/dynamic→React.lazy+Suspensenext/navigation→ mockuseRouter,usePathname,useSearchParams, etc.next/font/local→ honorsvariable, returns empty classNamenext/font/google→ configurable viasnapper.next.ts(see below)
Fonts
Real next/font/google downloads and injects fonts at build time. The
compat layer needs to know which fonts your app uses so it can return the
expected className for each call site.
// snapper.next.ts (auto-discovered at project root)
import type { NextCompatSetup } from "@awhitty/snapper/compat/next";
export default {
fonts: {
Inter: { className: "font-inter", variable: "--font-inter" },
GeistMono: { className: "font-geist-mono", variable: "--font-geist-mono" },
},
} satisfies NextCompatSetup;Load the actual @font-face rules through your host CSS (via the styles
option). The compat layer returns the className; your CSS delivers the font.
CLI
snapper # single run (one-shot)
snapper dev # watch mode with Ink TUI + workbench
snapper --log # single run, append-only plain log lines
snapper dev --log # watch mode, append-only plain log lines
snapper --ci # single run, text output, exits 0/1/2
snapper --ci --format json # single run, JSON output for CI
snapper approve # usage / list pending
snapper approve --all # approve every PNG in review/
snapper approve <id> # approve a specific id
snapper skill install [--global|--project] # Claude Code skill fileExit codes (in --ci mode):
0— all captures match baselines1— changes detected (new or diff-from-baseline)2— workbench failed to boot (real failure — check the emitted error)
Environment
SNAPPER_LOG=1— append-only plain log mode (onesnapper …line per event:ready,syncing,captured,summary,watching,changed,approved). Same mode assnapper --log/--plain.SNAPPER_NO_TUI=1— force the simple line-based logger (with ANSI accents) even on a TTY. Prefer--logorSNAPPER_LOG=1for agents.- Plain log mode also turns on automatically when any of these is set:
CLAUDE_CODE,CURSOR_AGENT,AI_AGENT, orNO_COLOR. Non-TTY shells still use the simple logger unless plain mode applies as above.
Claude Code skill
snapper ships a skill file for agents working in projects that use it. Install once globally:
snapper skill install # ~/.claude/skills/snapper/SKILL.md
snapper skill install --project # .claude/skills/snapper/SKILL.md
snapper skill status
snapper skill uninstallThe skill tells Claude what snapper does, when to reach for it, and how to diagnose a failing capture.
License
MIT
