A coding agent can build a page and even look at it, but it cannot measure it against the design pixel for pixel. Design Diff does. It overlays the design on a screenshot of the live page at the same pixel size and returns one number for what matched plus the box around what did not. Call it, read the score, fix what the box points at, and repeat until the page matches.
It reduces to two values an agent acts on.
matchPercent— how close the page is to the design.diffBounds— the box around what is still wrong.
Read the first to know when to stop, aim the next edit at the second, and loop. Everything else in this README exists to make those two numbers trustworthy.
Built agent-first. An agent or script drives it and reads back plain JSON. People get a slider and heatmap report to eyeball the same diff. And CI gets a hard pass/fail gate for merges.
# local design export
bunx design-diff http://localhost:3000 --png design.png --scale 1 --open
# or pull the export from Figma
bunx design-diff http://localhost:3000 --file <fileKey> --frame <node-id> --scale 1 --openNote: The first run downloads Chromium once, then every run is instant. Needs Bun.
--png <path> Design export (PNG) to compare against.
--file <key> Figma file key. Use with --frame instead of --png.
--frame <node-id> Figma frame to export (from the frame's URL).
--actual <path> Compare a local PNG instead of screenshotting the url.
--scale <n> Match the export: 1 for 1x, 2 for retina (default 1).
--threshold <0..1> pixelmatch colour threshold, lower is stricter (default 0.1).
--auth <path> Playwright storageState for pages behind login.
--out <dir> Where the report is written (default .design-diff).
--ignore <x,y,w,h> Rectangle (CSS px) to exclude from the diff. Repeatable.
--ignore-selector <sel> CSS selector whose elements are masked. Repeatable.
--wait-for <sel> Wait for this selector before capturing. Repeatable.
--no-overlay Skip writing overlay.html and heatmap.png.
--annotate Also write annotated.png with the diff box drawn on it.
--fail-under <pct> Exit 1 when the visual match is below this percentage.
--json Print the result as JSON to stdout and nothing else.
--open Open the report when done.
The design PNG sets the size, the page is screenshotted at the same pixel dimensions, so the two line up exactly. It waits for fonts and images to load and turns off animations before capturing. For the full walkthrough, see How it works.
With --file/--frame, it pulls the frame's size and PNG from the Figma API instead. Set DESIGN_DIFF_FIGMA_TOKEN to a Figma personal access token, created under Settings → Security → Personal access tokens. Read-only file access is enough, and view access to the file is all it needs.
Use --ignore to mask dynamic regions (avatars, timestamps) so they never count as differences. Coordinates are CSS pixels, repeat the flag for multiple boxes. Prefer --ignore-selector when the region moves or resizes. Every element matching the selector is masked by its live bounding box.
bunx design-diff http://localhost:3000 --png design.png \
--ignore 24,24,48,48 --ignore-selector "[data-dynamic], time"Use --wait-for to hold the capture until a selector appears, removing most “still loading” flakes. It errors if the selector never shows within 15s.
bunx design-diff http://localhost:3000 --png design.png --wait-for ".hero-loaded"Skip the browser entirely and diff two local PNGs with --actual. Handy offline, in tests, or when you already captured the screenshot elsewhere.
bunx design-diff --actual screenshot.png --png design.png --jsonNo page means no --wait-for, --ignore-selector, or readiness. --scale is ignored too. Bounds are reported in image pixels. Everything else works the same.
Save a logged-in session once, then pass it with --auth.
bunx playwright codegen --save-storage=auth.json https://your-app/login
# log in in the window, then close it
bunx design-diff https://your-app/dashboard --png design.png --auth auth.json --openauth.json holds cookies and localStorage. No credentials touch the tool. Redo it when the session expires.
Every run prints a short report and answers three questions.
DESIGN DIFF
────────────────────────
Visual match 83.5%
Diff bounds
x=0..1442
y=0..902
page coverage=100.0%
| Metric | What it tells you |
|---|---|
| Visual match | How many pixels line up. The headline score. |
| Diff bounds | The box enclosing every changed pixel, in CSS pixels. A tight box means one component is off, a box spanning the page points at a layout, viewport, or font problem. |
| Page coverage | How much of the page that box spans. Tells a local issue from a global one. |
The shape of the diff is the diagnosis. A tight box is one component off, a box spanning the page points at a font that never loaded or a viewport mismatch. Read the shape, not just the number.
The same numbers, plus the paths of every artifact, are written to metrics.json in the run folder for scripting.
{
"url": "http://localhost:3000",
"matchPercent": 83.52,
"diffPercent": 16.48,
"changedPixels": 214326,
"totalPixels": 1300684,
"coveragePercent": 100,
"diffBounds": { "x": 0, "y": 0, "width": 1442, "height": 902 },
"readiness": { "fontsReady": true, "imagesComplete": true }
}The overlay report draws the diff bounds as a box you can toggle on and off. Pass --no-overlay to skip the HTML report and heatmap when you only need the metrics.
Pass --annotate to also write annotated.png. The page screenshot with the diff box drawn on it. It needs no browser to view, so it drops straight into a PR comment or CI artifact. Nothing is written when there is no diff.
Use --json to print only the result to stdout, and --fail-under to turn the
visual match into a pass/fail gate (exit 1 when below the threshold).
bunx design-diff http://localhost:3000 --png design.png --json --fail-under 98--fail-under compares against Visual match (a percentage). It is separate
from --threshold, which sets how different a single pixel must be to count as
changed. --threshold is passed straight to
pixelmatch: a pixel is flagged when its
YIQ-weighted colour distance from the design exceeds that fraction of the maximum
possible distance, so it is a perceptual metric, not raw Euclidean RGB. Lower is
stricter, 0 flags any difference at all, and 0.1 is the default.
For an agent the contract is simple. Read the JSON, act on matchPercent and diffBounds, run again, and stop when the score clears your bar.
The CLI is a thin wrapper around designDiff, which returns the same data it
writes to metrics.json, including the paths of every artifact.
import { designDiff } from "design-diff";
const result = await designDiff({
url: "http://localhost:3000", // or actual: "screenshot.png" for image-vs-image
design: "design.png", // or { fileKey, frameId }
scale: 1,
threshold: 0.1,
ignore: [{ selector: "[data-dynamic]" }, { x: 24, y: 24, width: 48, height: 48 }],
waitFor: ".hero-loaded",
});
if (result.matchPercent < 98) process.exit(1);Running the tool in a loop? Reuse one browser instead of launching Chromium each time.
import { designDiff, launchBrowser } from "design-diff";
const browser = await launchBrowser();
try {
for (const url of urls) {
await designDiff({ url, design: "design.png", browser });
}
} finally {
await browser.close();
}- It compares pixels, not perception. Treat the score as a guide, not a verdict.
- Anti-aliasing and font rendering vary across machines, so a real page against a design export usually settles a point or two below 100 even when it looks right. Pick a threshold instead of chasing a perfect score.
- The design and the page must be the same size. A large mismatch is a hard error by design.
- A high score is necessary but not sufficient. A shifted component can still hide inside it, so keep the diff box and a human eye as the final check.
By @planetabhi ⋛⋋( ⊙◊⊙)⋌⋚
