All docs
Recipes5 min readUpdated

Fix flaky visual tests

Fix a flaky visual test by finding what varies between runs (the clock, animations, live data, randomness, fonts) and pinning it before the screenshot.

A flaky diff comes back changed when nothing actually changed. The screenshot is not lying - it faithfully caught something that moves between runs, like the clock or an animation frame. Remove that source and the flake goes with it.

What causes flaky visual tests?

  • The clock - relative timestamps like "2 hours ago", or a date rendered from Date.now().
  • Animations - an infinite CSS or JS animation caught mid-frame at a different point each run.
  • Live data - a component that fetches a real API renders different content every time.
  • Randomness - Math.random, generated ids, shuffled orders.
  • Third-party widgets - maps, embeds, ad slots that load their own moving content.
  • Web fonts - text captured before the font swaps in reflows on the next run.
  • Fonts loaded from Google at runtime - Google Fonts picks the font files by browser, so a capture from another machine or browser version renders text with different glyphs.

How do I fix each cause at record time?

  • Freeze the clock to a fixed instant so relative-time labels are identical every run.
  • Disable animations with animation: none under prefers-reduced-motion, which UI Verify emulates at capture - a near-zero animation-duration (or animation-iteration-count: 1) leaves an infinite animation running and can still flake under render load. In a story with a play() function (or a Storybook 10 .test()), reduced motion turns on only after it finishes, so your interaction test sees the same motion settings as in a plain Storybook run, and your prefers-reduced-motion CSS still applies to the screenshot. JavaScript that reads the setting once at mount (framer-motion's useReducedMotion, for example) sees it off in those stories, so gate an endless JS animation on `isUIVerify()` instead. This relies on the play-fn / test-fn tags Storybook writes into index.json (Storybook's tags docs, checked 2026-10-17, list both as applied automatically), so a story whose play Storybook cannot see when it builds the index (one spread in from another story, like { ...Default, args }) or a Storybook too old to write the tags keeps reduced motion on during play().
  • Leave animated images and videos alone - UI Verify pauses a playing or finished <video> on its first frame in every browser, and holds every animated GIF, WebP and APNG (as an <img> or a CSS background) on its first frame in Chromium and Firefox captures, so a spinner GIF needs no stub or still image. SVG <animate> animations are held still there too. WebKit captures still animate images and SVG. A video your story already paused stays where it is, but seeking one to a later frame (setting currentTime) may not land in a Chromium capture, so don't rely on it.
  • For a JavaScript animation no CSS media query can reach - a charting library's animate-on-mount prop, a <canvas> loop - branch on `isUIVerify()` to render the final frame only while capturing (isAnimationActive={!isUIVerify()}), including at SSR/build time via a UI_VERIFY env var.
  • Feed components fixture data instead of a live fetch.
  • Seed or stub any randomness to a fixed value - and when the value is chosen during server rendering, like a random image picked in a React component, seeding the browser RNG cannot reach it, so branch on `isUIVerify()` instead.
  • Stub third-party embeds behind a static placeholder, or mask them with `data-uiverify-ignore`.
  • Wait for fonts to load before the screenshot is taken.
  • Bundle your fonts at build time (next/font, @fontsource, or a font file in your repo) or load them from a pinned, versioned URL, instead of a runtime <link> to fonts.googleapis.com.
  • Wait for the loaded content before the screenshot, not a loading skeleton or spinner - a capture taken mid-fetch diffs against the loaded baseline.

Why does my screenshot show a spinner instead of the page?

A test that navigates and then asserts a URL can take its screenshot while the page is still fetching, so the capture is a spinner or a skeleton that diffs against the loaded baseline. Wait for the real content to appear before the test ends - a data row, not just its container. One trap on a page that swaps content in: a generic selector can match stale content still in the DOM mid-transition and resolve too early, so prefer waiting for network-idle or for an element that only exists once the new content has rendered.

Ignore a region you cannot make deterministic

Some content is supposed to differ and is not yours to pin: an ad slot, a map, a third-party widget, a live feed on a real page. Add the data-uiverify-ignore attribute to that element and UI Verify paints its box solid black in every capture, on the baseline and the new render alike, so what it shows never reaches the diff. Its size and position still count, and nothing inside the box is checked, so it is the last resort, not the first fix. See Ignore regions.

tsx
<aside data-uiverify-ignore className="h-64 w-80">
  <AdSlot id="sidebar" />
</aside>

How is fixing flake different in Storybook and on real pages?

For Storybook, control the variation inside the story - fixed props, frozen time, fixture data. For real pages driven by Playwright or Vitest, archive-replay bakes the page in at record time, so a live API or a feature flag cannot shift it on replay - but you still freeze the clock and stub moving widgets before you record. Animations are the exception to baking: the archived CSS re-runs on replay, so the same prefers-reduced-motion freeze applies to archives too, not just Storybook.

Archive-replay freezes each record, but a real page backed by live data - a catalog, star counts, listings - captures new data on the next record and diffs against the last. Pin the data at record time: intercept the API with committed fixtures, or point at a seeded backend, so re-records reproduce. Interception is also the only option in a fork, where a private staging backend is unreachable - see Visual testing on open source repos.
The full symptom-to-fix checklists live in the Storybook determinism and Playwright determinism skills, so your agent can apply them for you. Anything that still slips through is caught by automatic flake detection.

Visual testing for agents

UI Verify is visual regression testing for Playwright, Storybook and Vitest. Your coding agent sees what its UI change did and fixes it before users see it. See how it works.

Get started