Overlay a design on a screenshot of a live page at the same pixel size and get back two numbers you act on.
matchPercent, how close the page is to the design. Read it to know when to stop.diffBounds, the box in CSS pixels around what is still wrong. Aim the next edit here.
Run it, read the score, fix what the box points at, run again, and stop when the score clears your bar. The rest of this skill makes those two numbers trustworthy.
When to use
- Building a page or component from a design export or a Figma frame.
- Closing a design to code handoff, or hunting down visual drift.
- Verifying a built page matches a mockup before opening a PR.
- Gating CI on a minimum visual match.
Prerequisites
- Bun. Check with
bun --version. The CLI runs viabunx design-diff. The first run downloads Chromium once, then every run is instant. - A running page URL like
http://localhost:3000, or a local screenshot PNG. - A design source. A local PNG, or a Figma
fileKeyandframeIdwithDESIGN_DIFF_FIGMA_TOKENset. Read-only access is enough.
The loop
- Get a baseline. Run the tool against the page and the design. Prefer
--jsonso you can parse the result directly.bunx design-diff http://localhost:3000 --png design.png --scale 1 --json - Read the two values from the JSON.
matchPercentis the headline score.diffBoundsis{ x, y, width, height }in CSS pixels, ornullwhen there is no diff. - Diagnose from the shape, not just the number. See the table below.
- Make one targeted edit aimed at
diffBounds, then run step 1 again. - Stop when
matchPercentclears your bar. Real pages settle 1 to 2 points below 100 from anti-aliasing and font rendering, so pick a threshold instead of chasing a perfect score.
Reading the diff shape
The shape is the diagnosis. Read it before you open any report.
| Signal | Diagnosis |
|---|---|
Tight box, low coveragePercent |
One component is off. Wrong color, size, position, or spacing. |
Box spanning the page, high coveragePercent |
A global problem. A font that never loaded, a viewport or size mismatch, or a layout shift. |
readiness.fontsReady is false |
Captured before web fonts loaded. The score is not trustworthy yet. Wait for readiness, see below, and run again. |
diffBounds is null |
No differences at this threshold. |
Key flags
--png <path> Design export (PNG). Viewport is derived from it.
--file <key> --frame <id> Pull the export straight from Figma instead of --png.
--actual <path> Compare a local screenshot instead of visiting a url. No browser.
--scale <n> Match the export. 1 for 1x, 2 for retina. Default 1.
--threshold <0..1> Per-pixel color sensitivity. Default 0.1. Lower is stricter.
--ignore <x,y,w,h> Mask a fixed rectangle in CSS px. Repeatable.
--ignore-selector <sel> Mask every element matching a CSS selector. Repeatable.
--wait-for <sel> Hold capture until a selector appears. 15s timeout. Repeatable.
--auth <path> Playwright storageState JSON for pages behind login.
--fail-under <pct> Exit 1 when matchPercent is below this. CI gate.
--json Print only the metrics object to stdout.
--annotate Also write annotated.png with the diff box drawn on the page.
--no-overlay Skip the HTML report and heatmap. Faster, metrics only.
--out <dir> Output dir. Default .design-diff.
--open Open the HTML report when done.
Full walkthrough in How it works. See every flag with bunx design-diff --help.
Common scenarios
Tight agent loop, you already have a screenshot. Skip the browser and diff two PNGs. Fast, offline, sandbox friendly. Bounds come back in image pixels and --scale is ignored.
bunx design-diff --actual screenshot.png --png design.png --json
Pull the design from Figma. The frame id is the one in the frame's URL, like 10-2.
export DESIGN_DIFF_FIGMA_TOKEN=figd_your_token
bunx design-diff http://localhost:3000 --file abc123 --frame 10-2 --json
Mask what is meant to change, like avatars, timestamps, and live counters, so it never counts as a diff. Prefer --ignore-selector when the region moves or resizes.
bunx design-diff http://localhost:3000 --png design.png \
--ignore-selector "[data-dynamic], time, .avatar"
Wait for readiness when the page fetches content late. It fails loudly if the selector never appears within 15s.
bunx design-diff http://localhost:3000 --png design.png --wait-for ".hero-loaded"
Pages behind login. Save a session once, then reuse it. No credentials touch the tool.
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
CI gate. Turn the visual match into pass or fail.
bunx design-diff http://localhost:3000 --png design.png --json --fail-under 98
Retina export. Pass --scale 2 so a 2x PNG is measured honestly.
Programmatic API, for looping in code
The CLI is a thin wrapper over designDiff, which returns the same data written to metrics.json, including artifact paths.
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) {
// aim the next edit at result.diffBounds, then run again
}
Looping over many pages? 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();
}
Gotchas
--fail-underand--thresholdare different.--fail-underis a percentage gate on the whole-pagematchPercent.--thresholdis per-pixel color sensitivity passed to pixelmatch, where lower is stricter. Do not conflate them.- Same size or hard error. The design and the page must share dimensions. A large mismatch fails by design, so match
--scaleto the export. - A high score is necessary, not sufficient. A shifted component can hide inside a high match. Keep the diff box as the final check, plus the overlay or annotated image for a human.
--actualmode has no page. So--wait-for,--ignore-selector, andreadinessdo not apply, and bounds are in image pixels.- Trust the score only when the page was caught ready. When
readiness.fontsReadyis false, use--wait-forand run again before you act on the number.