Auditing a Component Library with Storybook and axe

A page scan tests the components that happen to be on the page, in the states they happen to be in, which for a design system is a small and arbitrary sample: the menu was closed, the field had no error, the toast never appeared. This guide is part of Design-System Accessibility Defaults, and it covers the sweep that fixes the sampling problem — visiting every story in the library, driving it into the state the story describes, and running axe against that one component’s subtree. The result is a per-component, per-state audit that runs in the library’s own pipeline and fails it on a regression.

Root Cause

The default configuration fails for stories in a specific and confusing way: every story reports the same three or four violations, none of which the component can fix. A Storybook preview iframe is a bare document — the story root is mounted directly under <body> with nothing else in it. There is no <main>, no landmark structure, no <h1>, and no skip link, so axe’s page-level rules fire on every single story. region reports that content sits outside a landmark, landmark-one-main reports the missing main, page-has-heading-one reports the missing top-level heading, and bypass reports the absent skip mechanism. All four are true statements about the harness and meaningless statements about the component, and if they are left enabled the sweep produces four findings per story multiplied by every story in the library — noise that guarantees nobody reads the output.

The correct response is not to add a fake landmark wrapper to the preview. A wrapper makes the rules pass without making them meaningful, and it changes the DOM the component renders into, which can mask real findings — a heading-order violation, for instance, depends on the surrounding heading levels. Disable the page-level rules explicitly for story runs, keep a short and reviewed list of exactly which ones are disabled, and make sure those rules still run somewhere: they are real criteria (WCAG 2.2 SC 1.3.1 and SC 2.4.1) and they belong in the application-level scan, where a page actually exists. This bare-harness problem is not unique to Storybook — the same tension appears when mounting components in a test runner, as in configuring cypress-axe for component testing — but Storybook is where the whole variant matrix already exists as declared, named artifacts, which is what makes it the right place for a library-wide sweep.

The second reason a naive sweep under-delivers is that a story’s rendered state is only its initial state. A Menu story renders a closed menu, so the popup markup does not exist and no rule can evaluate it. A Field story renders a pristine field, so the error region and its aria-describedby wiring are absent. Those states are exactly where component accessibility breaks, and reaching them requires driving the component before the scan — which is what a story’s play function does, and why the scan has to run after play rather than on load.

Why page-level rules are noise in a story The left panel shows the Storybook preview document: body, a single story root div, and the component, with four page-level rules marked as harness artifacts. The right panel shows an application document with html lang, a skip link, a header, a main landmark and an h1, where the same four rules are real findings. A band below lists the component-level rules that are meaningful in both. Storybook preview iframe Application document <body> <div id="storybook-root"> <Button /> </div> no landmark, no heading, no skip link <html lang="en"> <body> <a href="#main">Skip</a> <header> <nav> … </header> <main id="main"><h1>…</h1> structure exists, so structure is testable region · landmark-one-main page-has-heading-one · bypass 4 findings on every story, 0 of them fixable in a component region · landmark-one-main page-has-heading-one · bypass real SC 1.3.1 and SC 2.4.1 findings — keep them enabled here Meaningful in both: button-name · color-contrast · aria-* · label · focus-order-semantics The story sweep owns this row; the application scan owns the row above it.
Splitting the rule set by what the document can prove is what makes both scans trustworthy: neither reports a finding its subject could not fix.

Configuration

Three pieces make up the sweep: global parameters that set the rule set for every story, a test-runner configuration that injects axe and runs it after the story has finished settling, and per-story parameters for the handful of components that genuinely need a different rule set. Start with the globals, and keep the disabled list short enough to read in one glance — every entry is a rule the library has stopped checking, so each one deserves a comment saying which layer checks it instead.

// .storybook/preview.ts
import type { Preview } from '@storybook/react';
import '../packages/ui/src/focus.css';

const preview: Preview = {
  parameters: {
    a11y: {
      config: {
        rules: [
          // Page-structure rules: a story root has no page to structure.
          // All four stay enabled in the application-level scan.
          { id: 'region', enabled: false },
          { id: 'landmark-one-main', enabled: false },
          { id: 'page-has-heading-one', enabled: false },
          { id: 'bypass', enabled: false },
        ],
      },
      options: {
        // Run the conformance tags, not the experimental ones: an experimental
        // rule change in a minor axe release should not turn the library red.
        runOnly: {
          type: 'tag',
          values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'],
        },
      },
    },
    // Deterministic rendering: a mid-transition scan measures a mid-transition
    // colour, and color-contrast becomes the flakiest rule in the suite.
    chromatic: { disableSnapshot: true },
  },
  globalTypes: {
    theme: { defaultValue: 'light', toolbar: { items: ['light', 'dark'] } },
  },
};

export default preview;

The test-runner configuration is where the scan actually happens. postVisit is the important hook: it runs after the story has rendered and after its play function has completed, which is what lets a story drive itself into an interactive state before being measured. Reading the resolved story context inside the hook is what makes per-story overrides work — the runner sees the merged global and story parameters, so a story can tighten or relax the rule set without touching the global config.

// .storybook/test-runner.ts
import type { TestRunnerConfig } from '@storybook/test-runner';
import { getStoryContext } from '@storybook/test-runner';
import { injectAxe, configureAxe, checkA11y } from 'axe-playwright';

const config: TestRunnerConfig = {
  async preVisit(page) {
    // Inject once per page, before the story mounts, so the first story in a
    // worker is not scanned with a missing axe global.
    await injectAxe(page);
  },
  async postVisit(page, context) {
    // Merged parameters: globals from preview.ts plus this story's overrides.
    const storyContext = await getStoryContext(page, context);
    const a11y = storyContext.parameters?.a11y ?? {};
    if (a11y.disable) return;

    // A waiver must carry an expiry date; an expired waiver fails the run so a
    // "temporary" exception cannot outlive the sprint that created it.
    if (a11y.waive) {
      if (!a11y.waive.until || new Date(a11y.waive.until) < new Date()) {
        throw new Error(
          `${context.id}: a11y waiver missing or expired (${a11y.waive.until})`,
        );
      }
      return;
    }

    await page.waitForFunction(() => document.fonts.status === 'loaded');
    await configureAxe(page, { rules: a11y.config?.rules ?? [] });
    await checkA11y(
      page,
      '#storybook-root',              // scope to the story, not the harness
      {
        detailedReport: true,
        detailedReportOptions: { html: true },
        axeOptions: a11y.options ?? {},
      },
      false,                          // do not skip failures
      'default',                      // reporter: prints the failing selector
    );
  },
};

export default config;
What happens per story in the sweep Seven numbered steps: read the story index, open the story in the preview iframe, wait for render and fonts, run the play function to reach an interactive state, read the merged story parameters, configure and run axe against the story root, then write the result to the JUnit report. A note marks that the scan happens after play, which is what makes interactive states testable. One story, seven steps — and the scan is step six, not step three 1 read index.json 61 stories discovered 2 open the iframe ?id=ui-menu--default 3 await render fonts.status loaded 4 run play() menu opened, field focused 5 merge parameters globals + story overrides 6 axe.run(root) scoped to #storybook-root 7 write junit.xml one case per story postVisit fires after step 4, never before it a scan on load would test a closed menu and pass Steps 1 to 3 are the runner's; steps 4 and 5 come from the story file; steps 6 and 7 are the hook's. Scoping to the story root is what keeps the harness out of the result set. A thrown error in play() fails the story before any scan runs, which is the correct order.
Because the hook reads the resolved story context, the same runner configuration serves a strict default and a per-story exception without a branch for either.

The story file is where the interesting work happens, because a story is the only artifact that can declare a state. Write one story per state worth auditing rather than one per component, and use play to reach the states that only exist after interaction. Note the third story below: it deliberately opens the menu and waits for the popup to be in the document, so the scan measures the expanded widget — aria-expanded, the role="menu" container, its focus handling and the contrast of the popup surface, none of which exist in the default story.

// packages/ui/src/Menu.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { expect, userEvent, waitFor, within } from '@storybook/test';
import { Menu } from './Menu';

const meta: Meta<typeof Menu> = {
  title: 'ui/Menu',
  component: Menu,
  args: { label: 'Actions', items: ['Duplicate', 'Archive', 'Delete'] },
};
export default meta;
type Story = StoryObj<typeof Menu>;

export const Closed: Story = {};

export const Open: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    await userEvent.click(canvas.getByRole('button', { name: 'Actions' }));
    // Wait for the popup so postVisit does not race the open animation.
    await waitFor(() => expect(canvas.getByRole('menu')).toBeInTheDocument());
  },
};

export const SecondItemFocused: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    await userEvent.click(canvas.getByRole('button', { name: 'Actions' }));
    await waitFor(() => expect(canvas.getByRole('menu')).toBeInTheDocument());
    // Roving tabindex: arrow keys move focus, so the focused item is a state
    // with its own contrast and aria-current implications.
    await userEvent.keyboard('{ArrowDown}{ArrowDown}');
    await waitFor(() =>
      expect(canvas.getByRole('menuitem', { name: 'Archive' })).toHaveFocus());
  },
};

export const DangerItemDestructive: Story = {
  args: { items: ['Duplicate', 'Delete permanently'] },
  parameters: {
    // Scoped exception with an expiry the runner enforces. The danger tone is
    // 4.4:1 against the popup surface and is being retoned in A11Y-517.
    a11y: { waive: { until: '2026-09-30', ticket: 'A11Y-517' } },
  },
};

Validation

Run the sweep against a static build so the result does not depend on a dev server’s hot-reload state, and confirm three things: that the page-level rules no longer appear, that a genuine component finding does appear, and that an expired waiver fails. The last one is the check most teams skip, and it is the reason exceptions do not accumulate.

# Build once, serve the static output, sweep every story.
npm run build-storybook -- --quiet --output-dir storybook-static
npx concurrently -k -s first -n sb,test \
  "npx http-server storybook-static --port 6006 --silent" \
  "npx wait-on tcp:6006 && npx test-storybook --url http://127.0.0.1:6006 --ci"

# Expected output on a clean library:
#   Test Suites: 14 passed, 14 total
#   Tests:       61 passed, 61 total
#   Time:        189 s

# After breaking the danger tone in Menu (popup surface changed to surface.50):
#   ● ui/Menu › SecondItemFocused
#     Expected no accessibility violations but found 1:
#     color-contrast (serious) — Elements must meet minimum contrast ratio
#       #storybook-root > [role=menu] > [role=menuitem]:nth-child(2)
#       contrast 3.9:1, required 4.5:1
#   Tests: 60 passed, 1 failed, 61 total  -> exit 1

The disabled-rule list is itself a contract, so assert on it rather than trusting review. The check below reads the exported preview parameters and fails if anything outside the four page-structure rules has been switched off globally — which is the mechanism that stops color-contrast from being disabled at 5pm on a Friday and staying disabled for a year.

// packages/ui/src/preview-contract.test.ts — the disable list may not grow
import { expect, test } from 'vitest';
import preview from '../../../.storybook/preview';

// Only rules that describe a page, not a component, may be globally disabled.
const ALLOWED = new Set(['region', 'landmark-one-main', 'page-has-heading-one', 'bypass']);

test('no component-level rule is disabled globally', () => {
  const rules = preview.parameters?.a11y?.config?.rules ?? [];
  const disabled = rules.filter((r) => r.enabled === false).map((r) => r.id);
  expect(disabled.every((id) => ALLOWED.has(id))).toBe(true);
  // Also assert the list is complete: a missing entry means noisy stories,
  // and noisy stories are how a whole sweep gets switched off.
  expect(new Set(disabled)).toEqual(ALLOWED);
});
States scanned versus stories written For five components, a light bar shows the number of stories and a strong bar shows the number of distinct states scanned after play functions run: Menu two stories and five states, Field four and nine, Dialog two and four, Combobox three and eight, Toast two and five. Totals are thirteen stories and thirty-one scanned states. A play function is what turns a story into several audited states stories states scanned Menu 2 5 Field 4 9 Dialog 2 4 Combobox 3 8 Toast 2 5 13 stories, 31 audited states — the extra 18 are the ones a page scan never encounters. Field gains the most because error, disabled, focused and described states all differ structurally.
The gap between the two bars is the audit coverage that only exists because someone wrote a play function; without them the sweep tests thirteen pristine components.

Edge Cases and Conditional Guards

  • Portalled content. A dialog, popover or toast rendered with createPortal mounts under <body>, outside #storybook-root, so a root-scoped scan silently misses the entire widget. Detect it per story and scan document.body with an exclude for the harness chrome instead of narrowing the root — a passing result on an empty subtree is the worst possible outcome.
  • Theme and direction variants. A component that passes in the light theme can fail color-contrast in the dark one, and an RTL layout can break focus order. Loop the sweep over Storybook globals rather than duplicating every story per theme: run test-storybook once per theme value in the matrix and tag the run so the failure names both the story and the theme.
  • Async and animated states. A story whose icon or data arrives from a promise can be scanned empty, and one mid-transition can be scanned at an interpolated colour. Await a settled condition inside play — a role query, not a timeout — and set prefers-reduced-motion in the runner context so transitions collapse to their end state deterministically.

Pipeline Impact

The sweep is the most expensive stage in the component library’s gate, which makes its runtime the thing worth engineering. It parallelises cleanly because stories are independent: test-storybook --shard=1/4 across four jobs turns a three-minute sweep into fifty seconds, and each shard writes its own JUnit file for merging. Build Storybook once in a prior job and pass storybook-static as an artifact rather than rebuilding per shard, since the build is a fixed cost that does not shrink with parallelism.

Fail the job on any violation at serious or above, and report moderate and minor as annotations rather than failures — the same threshold used everywhere else in the pipeline, so a rule id has one meaning across the whole organisation. Upload the detailed axe report and the JUnit file on both green and red runs: the green run’s report is the coverage evidence (14 components, 61 stories, 31 interactive states, 0 serious findings) that an accessibility conformance statement can cite without a manual re-test.

One caution about scope. This sweep is an audit, not a lock: it proves the library’s components are clean against the current rule set at the current axe version, and an axe upgrade can legitimately change its output. When the goal is instead to freeze a specific accessible name, role or state so that no refactor can alter it, the right instrument is a committed tree snapshot as described in snapshot testing accessibility trees to prevent regressions. Run both: the sweep catches new violations across the whole matrix, the snapshot catches silent changes that violate no rule. And keep the sweep downstream of the compiler and linter checks in enforcing accessible component defaults in a design system, because a story that fails to type-check should never reach a browser.

Common Pitfalls

  • Leaving page-level rules enabled. Four unfixable findings per story is the fastest route to a sweep everyone ignores, and the second-fastest is faking a landmark wrapper so they pass.
  • Scanning on load instead of after play. Every interactive state reports clean because it never existed, and the sweep’s headline number becomes actively misleading.
  • Disabling a rule globally to silence one story. Use a story-scoped parameter with an expiry, and assert on the global list so it cannot grow quietly.
  • Scanning document when content is portalled, or #storybook-root when it is. One reintroduces harness noise, the other tests an empty subtree; decide per story and comment the reason.
  • Treating the addon panel as the gate. The Storybook accessibility panel is a development aid with no exit code; only the test runner blocks a merge.
  • Rebuilding Storybook inside every shard. The build dominates the runtime, so parallelism buys nothing until the static output is an artifact.

FAQ

Why not just run the application scan and skip the story sweep? Because the application scan can only see the states its routes happen to render. A component library with 14 components and 31 meaningful states will typically have fewer than half of them present on any crawlable page, and the missing ones — error states, expanded popups, focused items, empty and loading states — are where component accessibility fails. The sweep also attributes findings to a component and a state rather than to a URL, which is the difference between a fixable report and a triage exercise.

Is test-storybook the right runner, or should this be a Playwright suite? Use the test runner when the stories already exist, because it discovers them from the index and needs no per-story test file — 61 stories cost zero additional test code. Write a Playwright suite instead when the audit needs behaviour the runner cannot express: multiple viewports per story, cross-browser engines, or assertions that span two components. Both inject the same axe build, so the findings are comparable either way.

How should a genuine, known component violation be handled without turning the sweep off? With a story-scoped waiver that carries a ticket and an expiry date, as in the danger-tone story above, so the exception is visible in the story file, attributable to a work item, and self-destructing. Never use a global rule disable for a single component, and never delete the failing story — a deleted story removes the evidence along with the failure, and the finding silently returns to the backlog with nobody watching it.