Generating a variant matrix story for visual review

This page is part of the component variants section of the Storybook isolation workflows guide, which covers generating and naming variant stories.

The specific problem: your Button has three variants, three sizes and three states. Twenty-seven stories is a lot of clicking for a reviewer, and twenty-seven separate diffs is a lot of approving when a shared token changes.

Problem statement

Individually named variant stories are excellent for CI: a failure names the exact combination, which is what makes triage quick. They are poor for human review, because seeing whether the set is coherent — whether the sizes step evenly, whether the ghost variant reads at every size — means opening twenty-seven pages and holding them in your head.

The symptom appears the first time a spacing token changes: twenty-seven diffs, each showing a two-pixel shift, each requiring a click, and a reviewer who approves the twenty-seventh without looking.

Root cause

The two audiences want different things. A machine wants one assertion per combination so failures are addressable. A person wants one image so combinations can be compared against each other.

Trying to serve both with one story shape means giving something up. Rendering the whole matrix in a single story serves the person and loses the naming; generating twenty-seven serves the machine and loses the comparison.

Twenty-seven baselines, or one Top lane: one story per combination produces twenty-seven baselines and, when a shared token changes, twenty-seven diffs to review. Bottom lane: a single matrix story renders all twenty-seven variants at once, producing one baseline and one diff that shows every change in a single image. One story per combination — 27 baselines, 27 diffs Primary-sm Primary-md …24 more Ghost-lg One matrix story — 1 baseline, 1 diff to read matrix story renders all 27 1 screenshot 1 review

Minimal reproduction

// twenty-seven exports, and no view of the set as a whole
export const PrimarySm: Story = { args: { variant: 'primary', size: 'sm' } };
export const PrimaryMd: Story = { args: { variant: 'primary', size: 'md' } };
// … twenty-five more

Step-by-step fix

1. Build the matrix from the same constants the types use

// Button.types.ts — one source for the type and the story
export const VARIANTS = ['primary', 'secondary', 'ghost'] as const;
export const SIZES = ['sm', 'md', 'lg'] as const;
export type Variant = (typeof VARIANTS)[number];
export type Size = (typeof SIZES)[number];

What this does: adding a variant updates the type and the matrix together, so the story cannot fall behind the component.

2. Render the grid in one story

// Button.stories.tsx
export const Matrix: Story = {
  parameters: { controls: { disable: true } },   // a matrix has no single arg set
  render: () => (
    <table style={{ borderSpacing: '12px' }}>
      <thead>
        <tr>
          <th />
          {SIZES.map((s) => <th key={s} scope="col">{s}</th>)}
        </tr>
      </thead>
      <tbody>
        {VARIANTS.map((v) => (
          <tr key={v}>
            <th scope="row">{v}</th>
            {SIZES.map((s) => (
              <td key={s}><Button variant={v} size={s}>Save</Button></td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  ),
};

What this does: gives a reviewer the whole set in one view, with row and column headers so the axes are readable rather than implied by position.

3. Keep the states that change rendering as separate rows

const STATES = [
  { label: 'default', props: {} },
  { label: 'disabled', props: { disabled: true } },
  { label: 'loading', props: { loading: true } },
] as const;

What this does: extends the matrix along the third axis without exploding it into separate stories — the grid grows by rows rather than by files.

4. Snapshot the matrix as one baseline

await expect(page.locator('#storybook-root')).toHaveScreenshot('button-matrix.png', {
  maxDiffPixelRatio: 0.002,
});

What this does: one image, one diff. A token change produces a single review rather than twenty-seven.

5. Keep the individual stories for CI naming

Do not delete them. The matrix is for review; the generated per-variant stories are what let a failing check say Button/Ghost-lg instead of “something in the matrix”. Both cost little to maintain once they are generated from the same constants.

Verification

What a shared token change looks like to a reviewer A comparison of two review outputs after a single token change. The matrix story reports one change, and the reviewer sees all twenty-seven cells in one image. The per-variant approach reports twenty-seven separate changes. Reading the matrix image first and then approving the set takes about forty seconds against roughly six minutes of clicking through individual diffs. chromatic — token change, both approaches matrix story: 1 change Button/Matrix reviewer sees all 27 cells in one image per-variant: 27 changes Button/Primary-sm … Button/Ghost-lg triage: 1 image read, then approve 27 time to decision: ~40 s vs ~6 min

Add a fourth variant to the constant and rebuild. The matrix should gain a row without any story file being edited, and the generated per-variant stories should gain three exports. If either does not, that side is not reading the shared constant.

Edge cases and caveats

Three ways to cover a variant matrix Three rows. One story per variant produces twenty-seven baselines and twenty-seven diffs when a shared token changes, but names the failing combination precisely. A single matrix story produces one baseline and one diff, but you must find the changed cell by eye. Using both costs twenty-eight baselines and lets you read the matrix diff first, falling back to the individual stories only when triage needs a name. Approach Baselines Review cost on a token change Names a failure One story per variant 27 27 diffs yes, precisely One matrix story 1 1 diff no — you find it by eye Both 28 1 first, 27 if needed yes, after triage
  • Matrix stories and Controls. A story rendering many instances has no single set of args, so leaving Controls enabled offers a panel that changes nothing. Disable it for the matrix story so the panel does not mislead.

  • Layout sensitivity. Rendering in a table means a change to one cell can reflow its neighbours, so a small change produces a larger diff than it would in isolation. Fixing the cell dimensions keeps a change local to the cell that caused it.

  • Very large matrices. Past roughly forty cells the image is too dense to read, which defeats the purpose. Split it by axis — one matrix per state, rather than one matrix for everything.

Making the matrix readable

A grid of twenty-seven controls is only useful if a reviewer can tell which cell is which without counting. Three details do most of that work.

Label both axes in the markup, not by position. Using a real table with th elements scoped to row and column means the axes are visible in the image and announced correctly by a screen reader browsing the docs page. A grid of unlabelled divs looks fine to whoever built it and is unreadable to everyone else.

Fix the cell size. Letting cells size to their content means a longer label in one cell shifts every cell to its right, so a one-word change produces a diff across the whole row. A fixed width per column keeps a change where it happened, which is what makes the diff image worth reading.

Order the axes consistently with the rest of the system. If the documentation lists sizes small to large, the matrix should too. A matrix ordered differently from the docs makes a reviewer translate between them, and translation is where mistakes happen.

The end state to aim for is an image a designer can open and assess without a guide — because that is the thing the matrix does that twenty-seven separate stories cannot.

FAQ

Should the matrix story replace the individual variant stories?

No — it complements them. The matrix is a review artefact: it answers “does this set look coherent” in one glance. The individual stories are a CI artefact: they answer “which combination broke” without a human reading an image. Deleting either costs you something the other cannot provide, and both are generated from the same constants, so the marginal maintenance is near zero.

How do I stop the matrix diffing on every unrelated change?

Fix the cell dimensions and give the grid a stable layout, so a change to one component cannot reflow the rest. Disabling animations and pinning the device scale factor removes the remaining noise. What is left is genuine: if the matrix diffs, something in it actually changed.

Does this work for a component with a text-length axis?

Yes, and it is one of the more valuable uses. A row for a short label, a typical label and a deliberately long one catches truncation and wrapping bugs that no single-story snapshot would, because those failures only appear at the boundary between lengths.