Visual Regressions with Playwright and GitHub Actions

Visual regression testing will give you a lot of confidence when you ship UI changes, because it catches the kind of drift that unit tests and code review quietly miss. A padding tweak, a theme token rename, or a dialog that suddenly clips wrong can sail through a green PR and still look broken in the browser, and having CI screenshot your components before merge can save you from a production surprise or that awkward post-deploy thread where someone asks if the banner always looked like that. The goal for us was bigger than catching day to day regressions though. We also wanted a framework that would let us move off our current UI library someday with enough visual confidence to actually do it, where the presentational surfaces stay honest even when the primitives underneath change. At work I help maintain a React component library that is the presentational layer of a larger product, with Storybook as the day to day home for new features, edge cases, and the occasional portal that renders outside #storybook-root. We needed a cheap way to answer one boring question on every PR (did this change accidentally move pixels?), and a longer lived safety net for the day we swap foundations. So I wired visual regression into GitHub Actions with Playwright and Storybook tags, where CI owns the screenshots, a bot posts the result on the PR, and approving new baselines is as simple as commenting /approve-snapshots.

First, put the UI in its own package

Before the screenshot plumbing, the package boundary mattered. We pulled the presentational layer into a separate package in the monorepo so the contract stayed simple (props in, pixels out) while data fetching, routing, and business rules stayed in the app, and view code did not have to sit next to API clients in the same mental pile.

Separation of concerns sounds abstract until you open a folder and your brain has to hold styling, network shape, and product rules at once, which is exactly the cognitive load a package boundary is good at dividing. The filesystem forces a line that a single app folder rarely keeps honest, and that is one of the quieter wins of a monorepo: you still share types, tooling, and one PR across packages, but the presentational package has one job. In theory, once enough UI lives there, the product app stops inventing CSS for the same surfaces. In practice, stories and Playwright can sit next to the components they cover, scoped to one package, without dragging the whole application graph into every screenshot run. That same isolation is what makes a future UI library migration feel possible, because you can swap primitives underneath and still ask CI whether the presentational surfaces still look right. Visual regression worked better *because* of that split.

Just update the snapshots on your laptop?

The classic Playwright trap is familiar if you have lived it: you update snapshots on your Mac, CI runs on Linux, fonts render differently, and half the suite fails for reasons that have nothing to do with your change. Anti-aliasing, subpixel positioning, and hinting all conspire to produce different pixels for the same component, and we could have kept telling people to "just use Linux" or "just ignore the flake." Paid tools solve this too (Chromatic is great), but we wanted something that lived next to our existing Storybook stories, ran on our runners, and did not require every visual check to go through another vendor workflow.

Playwright already had toHaveScreenshot(), Storybook already had tags, and GitHub Actions already had PR comments and artifacts, so the pieces were sitting there. The decision that unlocked the rest was treating **CI as the source of truth**: baselines get written on Linux in Actions, and local Mac runs are for debugging, not for writing the golden images that gate merges.

Opt in with a tag

I did not want a separate visual test file for every component, because that is how these systems rot. Instead a story opts in by tagging itself:

ts
const meta = {
  title: 'Components/BetAmountInput',
  component: BetAmountInput,
  tags: ['visual'],
}

That is the whole contract for most cases: add visual, get a screenshot test, with no second inventory of selectors to keep in sync. A few extra tags showed up once real components punched holes in the happy path:

| Tag | Why it exists | | --- | --- | | visual | Default. Snapshot #storybook-root | | visual:fullpage | Dialogs, menus, popovers that portal outside the root | | visual:reduced-motion | Disable motion so transitions do not flake | | !visual | Opt a single story out when the meta already opted in |

Portaled UI is the big one: if a Dialog renders under document.body, a root-only screenshot quietly crops the thing you care about, so full page exists for those cases even though it is heavier and noisier and should only be used when the root clip lies. Animations look cool in Storybook and look like flake in pixel diffs, which is why reduced motion exists.

Kitchen Sink stories pack more pixels into one shot

Tagging every variant as its own visual story gets expensive fast: each story becomes another PNG, and with desktop plus mobile that multiplies again. The strategy I lean on instead is a KitchenSink story, one canvas that lays out the important variants and states side by side so a single screenshot covers more surface area without spinning up a separate baseline for every prop combination.

ts
export const KitchenSink: Story = {
  tags: ['visual'],
  render: () => {
    const colors = ['red', 'neutral', 'primary'] as const

    return (
      <Stack spacing={2} alignItems="center">
        {colors.map((color) => (
          <InfoButton key={color} color={color}>
            Label
          </InfoButton>
        ))}
      </Stack>
    )
  },
}

You still keep focused stories for docs and interaction when you need them, but for visual regression the Kitchen Sink is the optimization: more pixels under test, fewer snapshot files to store, fewer Playwright navigations to wait on, and one place to look when a shared style token shifts across the whole family. The tradeoff is that a diff lights up the whole sheet when anything in it changes, so I keep Kitchen Sinks dense but intentional rather than dumping every playground experiment into them.

One spec that discovers everything

The test file does not hardcode component names. On boot it hits Storybook's index.json, filters stories whose tags start with visual, and generates a Playwright test per story:

ts
const stories = await fetch('http://localhost:7777/index.json')
  .then((res) => res.json())
  .then((data) =>
    Object.values(data.entries).filter(
      (story) =>
        story.type === 'story' &&
        story.tags.some((tag) => tag.startsWith('visual')),
    ),
  )

for (const story of stories) {
  test(`Visual: ${story.title} - ${story.name}`, async ({ page }) => {
    if (story.tags.includes('visual:reduced-motion')) {
      await page.emulateMedia({ reducedMotion: 'reduce' })
    }

    await page.goto(`/iframe.html?id=${story.id}&viewMode=story`)
    await page.waitForLoadState('networkidle')

    if (story.tags.includes('visual:fullpage')) {
      await expect(page).toHaveScreenshot(`${story.id}.png`, { fullPage: true })
      return
    }

    await expect(page.locator('#storybook-root')).toHaveScreenshot(
      `${story.id}.png`,
    )
  })
}

Playwright starts Storybook via webServer in the config on the same port as local storybook dev, so CI and laptop share that path, and tagging a story is enough without a registry file or an import list someone forgets to update. We also delete orphaned PNGs when a story disappears or loses its tag, because snapshot folders get spooky otherwise, with hundreds of dead baselines hanging around and nobody sure if they still mean anything.

Make Chromium boring on purpose

Even on Linux, Chromium invents pixel noise if you let it, so in CI we launch with flags that trade pretty font rendering for stable pixels:

ts
launchOptions: process.env.CI
  ? {
      args: [
        '--font-render-hinting=none',
        '--disable-skia-runtime-opts',
        '--disable-font-subpixel-positioning',
        '--disable-lcd-text',
      ],
    }
  : undefined,

The text might look a little uglier, but the diffs get much more stable, and that is the trade I want in a visual gate. We run two projects against the same visual/ directory, desktop Chromium and a mobile viewport, so the same stories get two shapes and a layout that only breaks at phone width still fails the suite. Workers run in parallel, retries exist in CI, and traces stick around on failure: normal Playwright hygiene.

The loop

A lot of day-to-day visual QA can be over-simplified into a loop:

ts
// Pseudocode, not real code:
while (pr.open) {
  const result = await runVisualTests()
  const report = await uploadHtmlReport(result)
  await commentOnPr(report)

  if (result.passed) break

  // human reviews the side-by-side diffs...
  await waitForComment('/approve-snapshots')
  await updateBaselinesOnCi()
  await pushToPrBranch()
}

Visual tests only run when the UI package or the workflow itself changes, because there is no reason to burn runner minutes on an unrelated backend PR. One small but important trick is not letting the test step abort the job before the report uploads: capture the exit code, set an output like snapshot_differences=true, upload artifacts and comment with if: always(), then fail intentionally at the end, since reviewers need the HTML report more than they need a red X with no attachments.

The bot comment is the UX. On failure it points at the Actions run, tells you to download the playwright-report artifact, and explains the approval command; on success it just says you are good; and it edits its previous comment instead of spamming a new one every push, which is a tiny detail with a big quality-of-life payoff.

/approve-snapshots

This was the part I cared about most for day-to-day use, because I did not want people SSHing into anything or running a secret local script with the right OS. Review the diff, then comment:

text
/approve-snapshots

That comment kicks a second workflow that checks the comment landed on a PR, reacts so you know it heard you, checks out the PR head branch, reruns Playwright with --update-snapshots, commits only the snapshot directory if git sees changes, and pushes back to the branch, with Co-authored-by pointing at whoever typed the command.

yaml
on:
  issue_comment:
    types: [created]

jobs:
  update-snapshots:
    if: >
      github.event.issue.pull_request &&
      contains(github.event.comment.body, '/approve-snapshots')
    steps:
      # resolve PR head ref, checkout that branch,
      # install deps + browsers, then:
      - run: npx turbo run e2e -- --update-snapshots
      # git add the snapshots dir, commit, push

After the push, the visual workflow runs again on the updated branch, and if the new baselines match the PR goes green. If you approved too early or something else moved, you get another round of the same loop without any special tools. Draft PRs are underrated here: if you want to see visual impact before you are ready for review, open a draft, let CI paint the diffs, and iterate.

What not to do

A short list we learned the loud way:

If a story is intentionally unstable (live clocks, random avatars, stubbed network that still jitters), leave the tag off, because visual coverage works better as a spotlight than a floodlight.

Living with it

Once this landed, the snapshot folder grew into the hundreds of PNGs pretty quickly, which is expected when desktop and mobile both multiply everything, and we kept the workflow boring by just committing the images. Path filters matter more as the suite grows, and scoping the workflow to the UI package keeps the tax on the rest of the monorepo close to zero. Flakes still happen even with networkidle, reduced motion, and stable fonts, and none of that replaces writing stories with deterministic props. If your story fetches live data, your pixels will wander.

Why this shape worked

The presentational package gave us a clear place to put stories and baselines, Storybook was already the source of component truth there, Playwright was already in the toolchain, and GitHub Actions was already where PRs lived. Gluing those together meant the visual system showed up inside habits people already had: write or update a story, tag it, open a PR, read the diff report if CI yells, and approve when the change is intentional.

There is no separate visual QA app, no ritual of regenerating baselines on a Linux box, and no mystery failures that only reproduce for the person whose laptop fonts differ. Is it Chromatic? No. Is it perfect? Also no. Is it a visual safety net that has already caught padding shifts, broken dialog clipping, and "I swear I only touched the other file" moments? Yes, and that was the bar.

If you already have Storybook, you are closer to this than it feels: tag a story, screenshot the iframe, make Linux CI the only place baselines get written, and give humans a one-line approval path. The rest is plumbing.

Blog
Internet Explorer
Resume.pdf
About Me.txt