Skip to content
Loading

Ship Fast Without Breaking the UI: The Cypress + Chromatic Stack

Ship Fast Without Breaking the UI: The Cypress + Chromatic Stack hero image

The Problem: UI Regressions Are Silent and Expensive

I have shipped a change to a shared <Button> component that broke the visual layout of twelve different screens. Not the logic - the logic was fine. The tests were green. The PR was approved. It went to production on a Thursday afternoon.

By Friday morning, three Slack messages and one support ticket later, we found it: a padding tweak that looked fine in isolation had caused text to overflow inside a constrained card layout that existed in a completely different part of the app. Nobody had touched that card. Nobody had looked at it. The test suite had no idea it existed.

That is the specific failure mode that makes UI development hard: the change you made was correct in isolation, but its side effects across a shared component library are invisible to you.

Unit tests do not catch this. Snapshot tests catch it in the noisiest, most useless way possible - a diff of serialised HTML that tells you something changed but not whether it looks broken. Manual QA catches it if the QA engineer happened to visit every affected screen, which they did not because there are forty screens and one QA engineer.

The root cause is that UI is relational. A shared component is used in dozens of contexts. When you change it, you need to see every one of those contexts at once. Without tooling for that, you are flying blind.

The Stack: Chromatic Layers on Top of Cypress

The setup I've landed on is not two separate testing tools that run independently. Chromatic for Cypress is a plugin that integrates directly into your existing Cypress E2E tests. You do not need Storybook. You do not write separate visual test files. The visual regression coverage comes for free from the E2E tests you already have.

Here is how it works mechanically:

  1. You add @chromatic-com/cypress to your project
  2. While your Cypress tests run, Chromatic communicates with the browser via the Chrome DevTools Protocol (CDP)
  3. At each test, Chromatic captures a full archive of the page - DOM, CSS, fonts, assets - not just a flat screenshot
  4. Those archives are uploaded to Chromatic's cloud, where snapshots are generated and pixel-diffed against the baseline from your last accepted build
  5. Any visual change is surfaced in Chromatic's review UI, keyed to the specific Cypress test that produced it

The archive approach is worth pausing on. Because Chromatic saves the full DOM and assets, you can open any snapshot interactively inside the Chromatic app to debug it. You do not have to re-run the test locally to understand what the page looked like. The full state is preserved in the cloud.

Setup

bun add --dev chromatic @chromatic-com/cypress

Add the Chromatic support import to your cypress/support/e2e.js:

import "@chromatic-com/cypress/support";

Install the plugin in your cypress.config.js:

const { defineConfig } = require("cypress");
const { installPlugin } = require("@chromatic-com/cypress");

module.exports = defineConfig({
  e2e: {
    setupNodeEvents(on, config) {
      installPlugin(on, config);
    },
  },
});

When running Cypress, prefix the command with the CDP environment variable so Chromatic can communicate with the browser:

ELECTRON_EXTRA_LAUNCH_ARGS=--remote-debugging-port=9222 npx cypress run

Then run Chromatic against the captured archives:

npx chromatic --cypress -t=<YOUR_PROJECT_TOKEN>

That --cypress flag is what tells Chromatic to process the archives produced by your E2E run rather than a Storybook build. Chrome is mandatory - Chromatic relies on it for snapshotting and the CDP connection.

The Review Workflow

When Chromatic detects visual changes on a PR, it posts a status check to GitHub. If there are changes, the PR is blocked from merging until someone reviews and acts on the diffs.

In Chromatic's review UI, each changed test shows you:

  • A before snapshot (the baseline from your last approved build)
  • An after snapshot (what your branch produced)
  • A diff overlay highlighting the exact pixels that changed

You either accept the change (intentional - update the baseline) or reject it (unintentional - go fix the branch). Accepting is one click. Rejecting sends the PR back to the author with a clear visual record of what broke.

The review suite also includes split and unified diff modes, a spotlight mode to zoom into subtle changes, and strobe diff to catch shifts you would otherwise miss. These are not niceties - on a complex UI, a 2px shift in a shared layout component will absolutely escape you on a casual visual check.

The key thing: the diffs are indexed to your Cypress test names. When a change affects the checkout confirmation screen, you will see it under the test Checkout flow - submits the order and shows confirmation. The context is explicit. You know exactly what user flow was visually affected and can make an informed decision about whether to approve it.

Setting Up Chromatic in CI

name: Chromatic Visual Tests

on:
  push:
    branches-ignore:
      - main

jobs:
  cypress-chromatic:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0 # Required - Chromatic needs full git history to find the correct baseline

      - uses: oven-sh/setup-bun@v2

      - name: Install dependencies
        run: bun install

      - name: Build application
        run: bun run build

      - name: Run Cypress with CDP enabled
        run: ELECTRON_EXTRA_LAUNCH_ARGS=--remote-debugging-port=9222 npx cypress run
        env:
          CYPRESS_BASE_URL: http://localhost:3000

      - name: Run Chromatic
        run: npx chromatic --cypress -t=${{ secrets.CHROMATIC_PROJECT_TOKEN }} --exit-zero-on-changes=false

fetch-depth: 0 is mandatory. Chromatic uses git history to track baselines - shallow clones will cause it to compare against the wrong baseline and produce garbage diffs.

--exit-zero-on-changes=false is the setting that makes Chromatic actually gate the PR. Without it, visual changes are flagged but the status check still passes. Fine during initial rollout; remove it once your baselines are stable.

Pro-tip: Chromatic's cloud automatically parallelises snapshot generation across all your test cases. You do not need to configure workers. This means the Chromatic step is faster than you expect even on large test suites - the bottleneck is almost always the Cypress run itself.

What Cypress Provides on Top of the Visual Layer

The Chromatic integration answers: "Does it look right?"

Cypress - independently - answers: "Does it work?"

These are genuinely different questions. A form can look pixel-perfect and still fail to submit because the onSubmit handler is calling a deprecated API endpoint. A navigation flow can render correctly and break completely when it depends on auth state from a real session cookie. Chromatic cannot see any of this.

Cypress runs against your real running application, with a real browser, real DOM events, and real network requests. It covers the failure modes Chromatic cannot:

  • Form submission flows: fill fields, click submit, assert the API call was made with the right payload, assert the success state renders
  • Authentication gates: assert unauthenticated users are redirected, authenticated users are not
  • Multi-step flows: checkout funnels, onboarding wizards, anything where state accumulates across interactions
  • Network edge cases: mock a 500 from the API and assert the error state renders and the retry button works
  • Real browser behaviour: scroll events, focus management, keyboard navigation
// cypress/e2e/checkout.cy.js
describe("Checkout flow", () => {
  beforeEach(() => {
    cy.login();
    cy.intercept("POST", "/api/orders", { fixture: "order-success.json" }).as(
      "createOrder",
    );
  });

  it("submits the order and shows confirmation", () => {
    cy.visit("/cart");
    cy.findByRole("button", { name: /proceed to checkout/i }).click();
    cy.findByLabelText("Card number").type("4242424242424242");
    cy.findByLabelText("Expiry").type("12/28");
    cy.findByLabelText("CVC").type("123");
    cy.findByRole("button", { name: /place order/i }).click();

    cy.wait("@createOrder").its("request.body").should("include", {
      currency: "GBP",
    });

    cy.findByText("Order confirmed").should("be.visible");
    cy.url().should("include", "/orders/");
  });
});

This test cannot be replicated in Chromatic alone - it requires real session state, real network interception, and sequential interaction logic. But because Chromatic is watching during this test, the confirmation page also gets a snapshot. If that page ever shifts visually, you will see the diff keyed directly to this test.

Cypress handles the behaviour. Chromatic handles the pixels. They run together.

The Confidence Loop

The workflow, end to end:

  1. Write a Cypress E2E test for a user flow
  2. Chromatic captures the visual state of every page that test visits
  3. Chromatic establishes a baseline on first merge to main
  4. Make a change - to a component, a shared utility, a CSS variable
  5. Push the branch - Cypress runs, Chromatic re-captures all snapshots
  6. Chromatic surfaces every affected test where the UI changed visually
  7. Review the diffs - accept intentional changes, reject regressions, push fixes
  8. Cypress confirms the interaction flows still work
  9. Both checks green → merge → ship

Step 6 is the one that changes everything. You no longer have to reason about what your change might have affected. Chromatic shows you every test where the visual output changed, with before and after side by side, in a format you can click through in under two minutes on most PRs.

The "does this look right on mobile?" Slack message disappears. The "did anyone check the dark mode variant?" comment disappears. The surprise regression on the screen nobody looked at disappears. They are replaced by a named artefact in the PR that says: here is exactly what changed visually, here is who approved it, here is when.

The Benefit

The teams I've seen ship the fastest are not the ones with the fewest tests. They are the ones with the most specific tests - tests that catch the right failures without generating noise.

Chromatic and Cypress together cover the full surface area of what can go wrong in a frontend application. Chromatic is exhaustive about visual state and deliberately ignores behaviour. Cypress is exhaustive about behaviour and deliberately ignores pixels. Neither tool tries to do the other's job.

The integration is worth emphasising: because Chromatic attaches to your Cypress run rather than requiring a separate test suite, the overhead of adopting it is low. You write Cypress tests you would have written anyway. Chromatic gives you visual regression coverage on every one of them for free.

Ship the feature. Merge the PR. Deploy on a Friday. The stack has your back.