Accessibility Testing in Isolation

Testing a component in isolation is the ideal moment to catch accessibility defects, because the component is rendered on its own — no surrounding page markup to mask a missing label or a broken focus order. This page, part of the Storybook isolation workflows guide, shows how to run axe-core against every story: live feedback in the authoring UI, assertions inside the play function, and a hard gate in CI so a WCAG violation cannot merge unnoticed.

The approach layers three things that share a single audit engine — the @storybook/addon-a11y panel for authoring, an in-play assertion for interaction states, and @storybook/test-runner with axe-playwright for enforcement. Each step below includes runnable configuration and a Verify: line so you can confirm the audit actually fires before relying on it.


Prerequisites


Where the a11y audit runs

The addon panel, the play-function assertion, and the CI test-runner are three entry points into the same axe-core engine. Understanding that they share one ruleset explains why a violation you see in the Storybook panel is the exact one that fails CI.

How one axe-core engine serves the addon panel, the play function, and CI An isolated story on the left feeds a central axe-core engine. The engine feeds three outputs: the a11y addon panel providing authoring feedback, the play function asserting an interaction state, and the test-runner with axe-playwright gating the CI pipeline. Isolated story <Button /> axe-core one WCAG ruleset a11y addon panel authoring feedback (advisory) play function asserts interaction state test-runner + axe-playwright gates CI (fails the build)

The panel is advisory and never fails anything; the play-function assertion fails an individual story locally and in the test-runner; and the test-runner is what actually blocks a merge. Configure the ruleset once and all three stay in agreement.


Step-by-step implementation

Step 1 — Install and register the a11y addon

Add @storybook/addon-a11y so every story gains a live axe-core audit panel. In isolation the audit measures only the component under test, which is exactly the granularity you want.

npx storybook add @storybook/addon-a11y
# This installs the package AND registers it in .storybook/main.ts.
# If you add it manually, ensure the addons array includes it:
// .storybook/main.ts
import type { StorybookConfig } from '@storybook/react-vite';

const config: StorybookConfig = {
  stories: ['../src/**/*.stories.@(ts|tsx)'],
  addons: [
    '@storybook/addon-essentials',
    '@storybook/addon-interactions',
    '@storybook/addon-a11y',   // registers the Accessibility panel
  ],
  framework: { name: '@storybook/react-vite', options: {} },
};

export default config;

Verify: Start Storybook with npm run storybook, open any component, and confirm an “Accessibility” tab appears in the addon panel listing Violations, Passes, and Incomplete counts. If the tab is missing, the addon is not registered in main.ts.


Step 2 — Configure axe rules and WCAG tags globally

Set a project-wide ruleset in .storybook/preview.ts so every story is audited against the same WCAG level. Centralising this prevents each story from drifting to its own standard.

// .storybook/preview.ts
import type { Preview } from '@storybook/react';

const preview: Preview = {
  parameters: {
    a11y: {
      // Only report violations at WCAG 2.1 A and AA — the level we enforce.
      config: {
        rules: [
          // Landmark rules are noise for isolated components (no <main>).
          { id: 'region', enabled: false },
        ],
      },
      options: {
        runOnly: {
          type: 'tag',
          values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'],
        },
      },
    },
  },
};

export default preview;

Verify: Open a component that renders no page landmark. The previously reported “region” violation should be gone, while genuine issues (missing button name, low contrast) still appear. This confirms your runOnly tags and disabled rules are being applied.


Step 3 — Assert accessibility inside the play function

The panel audits the default render, but real defects hide in interaction states — an open menu, an expanded accordion. Run axe from the play function so the audit targets the exact state you drove the component into, and fails the story on a violation.

// Menu.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { within, userEvent, expect } from '@storybook/test';
import { run as axeRun } from 'axe-core';
import { Menu } from './Menu';

const meta: Meta<typeof Menu> = { component: Menu };
export default meta;
type Story = StoryObj<typeof Menu>;

export const OpenState: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    // Drive the component into the state we actually care about.
    await userEvent.click(canvas.getByRole('button', { name: 'Options' }));
    await expect(canvas.getByRole('menu')).toBeVisible();

    // Audit THIS state — the open menu — not the closed default.
    const results = await axeRun(canvasElement, {
      runOnly: ['wcag2a', 'wcag2aa'],
    });
    // Fail the story if the open menu has any WCAG A/AA violation.
    expect(results.violations).toHaveLength(0);
  },
};

Verify: Temporarily remove the aria-label from the menu trigger. The OpenState story should turn red in the Interactions panel with an axe violation naming the button, proving the assertion runs against the opened state.


Step 4 — Gate the suite in CI with the test-runner

The panel and play assertions help authors, but only @storybook/test-runner enforces standards on every pull request. Wire axe-playwright into its postVisit hook so each story is audited headlessly and violations fail the job.

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

const config: TestRunnerConfig = {
  async preVisit(page) {
    await injectAxe(page);   // load axe-core into the page under test
  },
  async postVisit(page, context) {
    // Respect each story's own a11y parameters (disabled rules, etc.).
    const storyContext = await getStoryContext(page, context);
    if (storyContext.parameters?.a11y?.disable) return;

    await configureAxe(page, {
      rules: storyContext.parameters?.a11y?.config?.rules ?? [],
    });
    await checkA11y(page, '#storybook-root', {
      detailedReport: true,
      detailedReportOptions: { html: true },
      axeOptions: { runOnly: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
    });
  },
};

export default config;
# .github/workflows/a11y.yml — run the audit on every pull request
name: Storybook Accessibility
on: [pull_request]
jobs:
  a11y:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npm run build-storybook --quiet
      - name: Serve Storybook and run the a11y test-runner
        run: |
          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"

Verify: Push a branch containing a story with a known contrast violation. The GitHub Actions job should fail at the test-storybook step with a detailed axe report naming the story and rule, and the merge should be blocked.


Step 5 — Triage and annotate false positives per story

Some violations are artifacts of isolation, not real defects — a token measured against Storybook’s white canvas, or a landmark rule firing on a fragment. Scope the exception to the single story via parameters.a11y, and document why, so the suppression is auditable.

// Badge.stories.tsx — a per-story, documented exception
export const OnDarkSurface: Story = {
  parameters: {
    // This badge is only ever used on the dark app header. In isolation
    // Storybook renders it on white, so fix the CONTEXT, not the rule:
    backgrounds: { default: 'header', values: [{ name: 'header', value: '#1f2933' }] },
  },
};

export const LegacyIconButton: Story = {
  parameters: {
    a11y: {
      config: {
        rules: [
          // KNOWN: third-party icon font exposes no accessible name; tracked
          // in JIRA DS-482. Scoped to this story only, not disabled globally.
          { id: 'button-name', enabled: false },
        ],
      },
    },
  },
};

Verify: Re-run npx test-storybook. The OnDarkSurface story now passes because the audit measures the real surface colour, and LegacyIconButton passes only for its scoped rule — every other story still enforces button-name. Confirm by checking a different story with a missing name still fails.


Configuration reference

Option Location Type Default Effect
addons: ['@storybook/addon-a11y'] .storybook/main.ts string[] Registers the live Accessibility panel backed by axe-core
parameters.a11y.config.rules preview or story RuleObject[] [] Enables/disables individual axe rules by id; merges story over global
parameters.a11y.options.runOnly preview or story object all rules Restricts the audit to specific WCAG tags (e.g. wcag2aa)
parameters.a11y.disable story boolean false Skips the a11y audit entirely for a story (use sparingly)
parameters.a11y.manual story boolean false Renders the panel but requires a manual run instead of auto-audit
injectAxe(page) test-runner.ts preVisit function Loads axe-core into the headless page before the story renders
checkA11y(page, ctx, opts) test-runner.ts postVisit function Runs the audit and throws on violations, failing the CI job
detailedReport checkA11y options boolean false Emits a per-node report identifying the exact failing element
axeOptions.runOnly checkA11y options string[] all The WCAG tag set enforced in CI — keep in sync with preview.ts

Common pitfalls

1. Believing the addon panel gates your build. The @storybook/addon-a11y panel is authoring feedback only — it will show a red violation count and never fail anything. Teams routinely ship components with visible violations because nobody wired the test-runner. The panel informs; @storybook/test-runner with axe-playwright enforces. You need both.

2. Auditing only the default render. Running axe against a story’s initial state misses every defect that appears after interaction — an open dialog that traps focus incorrectly, a menu whose items lack roles. Drive the component into the state under test with the play function first, then audit, as in Step 3. The states users actually reach are the states that must pass.

3. Disabling color-contrast to silence canvas artifacts. axe measures contrast against whatever background the component renders on. In isolation that is Storybook’s white canvas, so a token designed for a dark surface fails. Disabling the rule hides real contrast bugs everywhere else. Fix the context instead — set the story’s backgrounds parameter to the real surface colour so the audit measures reality.

4. Disabling a rule globally to fix one story. Putting a rule exception in preview.ts turns off enforcement for the entire component library to accommodate a single known limitation. Scope every exception to the individual story via parameters.a11y.config.rules, and add a comment linking to the tracking issue so the suppression is reviewable rather than a silent, project-wide hole.

5. Letting the CI ruleset drift from the authoring ruleset. If preview.ts enforces wcag21aa but the test-runner’s checkA11y uses the axe default of all rules, authors see one set of violations locally and CI fails on a different set. Keep the runOnly tag list identical in both places — a single shared constant imported into both is the reliable way to prevent drift.


Integration point

Accessibility testing shares its execution model with the rest of the isolation workflow. The audits in Step 3 run inside the same play function used for interaction testing, so a single story can drive a flow and assert both its behaviour and its accessibility in one pass. The a11y addon is one member of the broader addon ecosystem that design-system maintainers assemble, and it composes with the backgrounds and viewport addons referenced above to make audits measure realistic conditions. Enforcement, finally, belongs to your pipeline: the test-runner job is one of several checks covered in CI/CD test gating, and it should be a required status check so a WCAG violation blocks the merge exactly like a failing unit test.

For the two deeper walkthroughs, see running axe-core in Storybook play functions for the full in-play assertion pattern including per-state audits, and configuring the Storybook a11y addon for WCAG checks for the complete ruleset and conformance-level configuration.


FAQ

Does the Storybook a11y addon fail my build, or only warn?

The addon panel is advisory — it highlights violations in the Storybook UI but never fails anything on its own. To turn a11y results into a build gate you run @storybook/test-runner with axe-playwright in CI: its postVisit hook executes the same axe-core audit headlessly and throws on violations, which fails the job. The addon is for authoring feedback; the test-runner is for enforcement.

How do I disable a specific axe rule for one story without affecting others?

Set parameters.a11y.config.rules on that single story with the rule id and enabled: false. Because Storybook merges parameters from the story up through the component and preview levels, a story-level override affects only that story. Always add a comment explaining why the rule is disabled so the exception is auditable rather than a silent suppression.

Why does axe-core report a colour-contrast violation that looks fine to me?

axe-core computes contrast from the actual rendered foreground and background colours. In an isolated story the component often renders on Storybook’s default white canvas rather than its real surface, so a token designed for a dark panel is measured against white and fails. Fix it by setting the story’s backgrounds parameter to the real surface colour, not by disabling the colour-contrast rule.

Should accessibility checks run in the play function or only in CI?

Both, and they share one audit. Running axe inside the play function gives authors immediate feedback and lets you assert accessibility of a specific interaction state, such as an open menu. Running the test-runner in CI enforces the same checks across every story on every pull request. The play-function assertion is your inner loop; the CI gate is the backstop that prevents a violation from merging.