shots: visual regression
shots takes a full-page screenshot of each page at each viewport and compares it with the screenshot recorded last time you accepted the site’s look. A CSS change that shifts a layout on a page you did not open, a font that stopped loading, an image that went missing: they show up as a percentage of changed pixels and a diff image, before the change is published.
It can also record the page’s animations as GIFs, so you can review motion without opening a browser.
npm i -D puppeteernpm i -D sharp # only to write motion GIFsThe audit loads pages over HTTP (requires: 'server'): vidimus serves the build locally, or uses --origin. sharp is loaded only when a page actually animates; set shots.motion to false to never need it. The audit is not in the default set.
Run it
Section titled “Run it”Record a baseline first, then compare against it:
npx vidimus shots --update-baseline # record the current screenshots as the baselinenpx vidimus shots # compare with the baseline--update-baseline is the same as --set shots.updateBaseline=true.
What it checks
Section titled “What it checks”Which pages
Section titled “Which pages”Every built page that is not excluded, in the default locale unless shots.allLocales or the top-level allLocales (--all-locales) is true. With shots.sample, pages whose URL path matches a pattern are reduced to the first matching page per pattern; pages that match no pattern are all captured. On a large site, sample every repeating template:
shots: { sample: ['^/blog/[^/]+/$', '^/tags/[^/]+/$', '^/docs/.+'] }Capturing
Section titled “Capturing”For each page and each entry in shots.viewports, vidimus:
- sets the viewport (a number is a width with a height of 800px; widths below 768px are emulated as a mobile device with touch),
- turns on
prefers-reduced-motion: reduce, so sites that respect it render without animation, - loads the page (ready at
render.waitFor, theloadevent by default), forces lazy images to load and decode before painting, and waits for stylesheets added after load, web fonts and images up toshots.settleTimeout, - with
shots.freeze, waits for animation-frame loops to stop and stills videos and endless CSS animations, see Canvas, WebGL, video, gifs and embeds, - masks
shots.maskelements and, withshots.maskEmbeds, cross-origin iframes, - grows the viewport to the page height, waits for images again (a
<picture>picks its source again when the viewport changes), repeats while late content changes the height, and takes the PNG screenshot into.vidimus/shots/current/.
A screenshot that fails (the page does not load, or the browser times out) is a failing finding, failed to capture <name>, with the browser error as detail; the other screenshots are still taken.
Screenshots are named after the URL path and viewport: index@375x667.png, blog_first-post@1280x800.png.
Comparing
Section titled “Comparing”Each current screenshot is compared with the file of the same name in the baseline directory, .vidimus/shots/baseline/ unless shots.baselineDir is set. A pixel counts as changed when any colour channel or alpha differs by more than shots.tolerance (0 to 255). When the two images differ in size, the comparison covers the larger of both, so a page that grew taller counts the new area as changed.
- No changed pixels: the screenshot is logged as
unchanged. - Some changed pixels: a diff image is written to
.vidimus/shots/diff/, with changed pixels in red over a faded grey copy of the current screenshot, and the page is added to the side-by-side gallery.vidimus/shots/diff.html(baseline, current, diff). - More than
shots.maxDiffof the pixels changed (default0.002, which is 0.2%): the screenshot is a failing finding.
Screenshots with no baseline, such as new pages, are listed as no baseline and reported as a warning (2 screenshot(s) have no baseline). With no baseline at all nothing was compared, and the audit fails (no baseline in .vidimus/shots/baseline: nothing was compared): record one with --update-baseline. In CI, see Sharing the baseline.
--update-baseline skips the comparison, deletes the PNG files in the baseline directory and copies the current screenshots in, so baselines of pages that no longer exist are removed. Other files in that directory are left alone. If any screenshot fails, the baseline is left as it was.
Sharing the baseline
Section titled “Sharing the baseline”By default the baseline lives inside .vidimus/, which ignores itself with its own .gitignore, so it stays on the machine that recorded it. To compare in CI, either:
- restore
.vidimus/shots/baseline/from a cache or artifact of an earlier run (see CI), or - commit it: point
shots.baselineDirat a directory outside.vidimus/, such as'shots-baseline'(relative toroot), and commit that directory after--update-baseline.
Motion
Section titled “Motion”Unless shots.motion is false, each page is then reloaded with reduced motion off. Animations that repeat forever are paused, and vidimus takes a viewport screenshot every motion.interval ms until motion.stableFrames consecutive frames are identical or motion.maxFrames frames were taken. If anything moved, the frames are written as .vidimus/shots/motion/<name>.gif with their real timing, and the log line notes the frame count, or (never settled) when it hit maxFrames, naming up to three elements that were still moving when it can tell: (never settled: canvas#scene animates). Freezing does not apply to this pass; masks do. Motion GIFs are for review only; they are not compared with anything.
Canvas, WebGL, video, gifs and embeds
Section titled “Canvas, WebGL, video, gifs and embeds”Pages that keep changing on their own would diff on every run. Four things keep the main screenshot stable:
- Reduced motion. The main screenshot is taken with
prefers-reduced-motion: reduce. A scene that honours it, by rendering one frame instead of starting its loop, is stable without anything else; this is the best fix when you own the code. shots.freeze(defaulttrue) runs a script before the page’s own scripts. It seedsMath.random, replacesperformance.now()and therequestAnimationFrametimestamps with a virtual clock that advances 1/60 s per frame, and stops callingrequestAnimationFramecallbacks after 30 frames. A three.js or canvas scene that animates by time or by random numbers then stops on the same frame every run. Before the screenshot, videos are paused at their first frame, with their metadata loaded so the controls show the duration, and CSS animations that repeat forever are paused at their start. This is best effort:Date.now(), timers, workers, WebAssembly and anything streamed from the network are not controlled.shots.maskis a list of CSS selectors, painted flat black in place (through inline styles, which a strict CSP allows) before the screenshot, the same in the baseline and the current screenshot, so they never diff. Use it for maps, ads, live counters, GIFs and anything else freezing cannot reach. An invalid selector is logged and skipped.shots.maskEmbeds(defaulttrue) masks every<iframe>whosesrcis on another origin (YouTube, maps, CodePen): they load late and change content. Same-origin iframes are captured.
WebGL in headless Chrome renders in software. vidimus starts the shots browser with --use-angle=swiftshader --enable-unsafe-swiftshader, so WebGL works the same way on every machine and in CI, though not pixel-identical to a real GPU: record the baseline on the same kind of machine that compares it, and raise shots.tolerance if antialiasing still differs. A canvas whose WebGL context could not be created is logged, index@1280x800: no WebGL context for canvas#scene, the shot shows it blank, instead of silently comparing an empty box.
export default defineConfig({ shots: { mask: ['.map', '[data-live]', 'img[src$=".gif"]'] },});Example output
Section titled “Example output”─── shots ───────────────────────────────────────────────────────
about@1280x800.png unchangedabout@375x667.png unchangedblog@1280x800.png 0.08% changed -> diff/blog@1280x800.pngindex@1280x800.png 4.61% changed -> diff/index@1280x800.png 23 frames -> motion/index@1280x800.gifindex@375x667.png 3.92% changed -> diff/index@375x667.png 23 frames -> motion/index@375x667.gifpricing@1280x800.png no baselinepricing@375x667.png no baselineside-by-side gallery at .vidimus/shots/diff.html
✖ index@1280x800: 4.61% changed > 0.20% allowed .vidimus/shots/diff/index@1280x800.png → Open .vidimus/shots/diff.html to review; if the change is intended, run vidimus shots --update-baseline.
✖ index@375x667: 3.92% changed > 0.20% allowed .vidimus/shots/diff/index@375x667.png → Open .vidimus/shots/diff.html to review; if the change is intended, run vidimus shots --update-baseline.
⚠ 2 screenshot(s) have no baseline pricing@1280x800.png pricing@375x667.png → Run vidimus shots --update-baseline, or add the new pages to shots.exclude.
✖ shots: 4 pages x 2 viewports (375x667, 1280x800), 2 screenshot(s) changed beyond the allowed diff, 2 without baseline (31.5s)blog@1280x800 changed by less than maxDiff, so it is in the gallery but not a finding. A passing run, and a baseline update:
✔ shots: 4 pages x 2 viewports (375x667, 1280x800), 1 changed within tolerance (28.0s)✔ shots: baseline updated from 8 screenshots in .vidimus/shots/baseline (27.4s)Options
Section titled “Options”| Key | Default | Description |
|---|---|---|
shots.viewports |
[{ width: 375, height: 667 }, 1280] |
a width in px (height 800) or { width, height } |
shots.sample |
[] |
URL path patterns: screenshot one page per matching template |
shots.allLocales |
false |
also capture pages of translated locales |
shots.tolerance |
12 |
per-channel colour difference (0 to 255) a pixel may have and still count as unchanged |
shots.maxDiff |
0.002 |
share of changed pixels allowed before a screenshot fails |
shots.freeze |
true |
stop animation-frame loops, seed Math.random, still videos and endless CSS animations before the screenshot |
shots.mask |
[] |
CSS selectors painted flat black before the screenshot |
shots.maskEmbeds |
true |
also mask cross-origin iframes |
shots.motion |
{ interval: 100, stableFrames: 5, maxFrames: 60 } |
record animations as GIFs, false to skip |
shots.settleTimeout |
10000 |
ms to wait for fonts and images before the screenshot |
shots.protocolTimeout |
600000 |
ms the browser may take for one operation, such as a very tall screenshot |
shots.concurrency |
half the cores (2 to 8) | browser tabs used at the same time |
shots.outDir |
'shots' |
directory inside outDir (default .vidimus) |
shots.baselineDir |
'' |
baseline directory relative to root; empty means baseline inside shots.outDir |
shots.updateBaseline |
false |
record instead of compare; set by --update-baseline |
shots.exclude |
[] |
URL path patterns to skip |
Config example
Section titled “Config example”import { defineConfig } from 'vidimus';
export default defineConfig({ audits: ['i18n', 'csp', 'a11y', 'links', 'r12s', 'shots'], shots: { viewports: [{ width: 375, height: 667 }, 768, 1440], sample: ['^/blog/[^/]+/$'], exclude: ['^/changelog/'], maxDiff: 0.005, motion: false, },});- Screenshots depend on the fonts and rendering of the machine that takes them. Record the baseline on the same kind of machine that compares it, for example in the same CI container, or anti-aliasing differences alone can exceed
maxDiff. - Content that changes on every build (dates, random images, a “latest posts” list) changes pixels too. Exclude those pages, sample a stable page of the template, or raise
maxDifffor the whole audit. - The gallery
diff.htmlreferences the PNGs by relative path, so keep thecurrentanddiffdirectories next to it, and the baseline directory where it was, when you upload it as a CI artifact.
These docs are deliberately built 8 times, with VitePress, Starlight, Hugo, Eleventy, Zola, mdBook, React and Angular, from the same markdown, and audited by vidimus as one site. Why.