Running axe-core in Storybook play functions

This page is part of the Accessibility Testing in Isolation section, which covers catching WCAG violations at the component level before they reach an assembled page. The specific goal here is concrete: you want a story to turn red — in the sidebar and in CI — the moment it introduces an accessibility violation, not merely display a report you have to remember to read.

The play function is the right place to do this because it already runs after render as part of Storybook’s test lifecycle. If axe-core runs there and throws on a violation, the failure flows through the same machinery that reports a failed interaction test.

Problem statement

Most teams install @storybook/addon-a11y, see the violations panel light up, and assume they now have accessibility testing. They do not. The panel is a passive report: it runs axe-core, lists what it found, and colours a tab — but it never fails anything. A story with a contrast violation and a missing form label renders exactly as green in the sidebar as a perfect one. In CI, where nobody is looking at the panel, the violation is completely invisible.

What you actually want is an assertion: given this story, axe-core must report zero violations, and if it does not, the story fails. That failure has to propagate to two places — the Storybook sidebar (so a developer sees the story is broken while working on it) and the test-runner (so the pipeline blocks the merge). The play function is the hook that makes both happen, because it runs as part of the test lifecycle rather than as a display-only add-on.

Root cause

The addon and the assertion are two different things running the same engine. @storybook/addon-a11y calls axe-core to populate a panel; it deliberately does not throw, because a panel that crashed the story on every violation would make Storybook unusable as a development tool. So by default there is nothing in the pipeline that converts “axe found a violation” into “this test failed”. You have to add that conversion yourself, inside the play function where the story’s render lifecycle already exposes a hook that participates in the test run. Until you call axe and assert on its result there, violations are reported but never enforced.

Passive a11y panel versus an asserting play function Left column: story renders, the a11y addon runs axe and paints a panel, the story stays green. Right column: story renders, the play function runs axe via checkA11y, which throws on a violation and marks the story failed in the sidebar and the test-runner. Addon panel (passive) Play function (asserting) story renders addon runs axe → paints panel violation listed in tab story stays green nothing fails in CI story renders play() runs checkA11y throws on violation story marked failed test-runner fails the pipeline

Minimal reproduction

Here is a component with a real WCAG violation — an icon-only button with no accessible name — and a story that renders it. The a11y panel will flag it, but the story passes.

// IconButton.tsx — the ⓧ button has no accessible name (WCAG 4.1.2)
export function IconButton({ onClick }: { onClick: () => void }) {
  return (
    <button onClick={onClick} className="icon-btn">
      <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
        <path d="M4 4 L12 12 M12 4 L4 12" stroke="currentColor" strokeWidth="2" />
      </svg>
      {/* no text, no aria-label → screen reader announces "button" with no name */}
    </button>
  );
}
// IconButton.stories.tsx — renders, panel flags it, but the story passes green
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';
import { IconButton } from './IconButton';

const meta: Meta<typeof IconButton> = {
  component: IconButton,
  args: { onClick: fn() },
};
export default meta;

export const Default: StoryObj<typeof IconButton> = {};

Run the test-runner against this and it reports success — the violation is real, but nothing asserts on it:

 PASS   src/IconButton.stories.tsx
   ✓ Default (61 ms)

Step-by-step fix

1. Install the accessibility test dependencies

Add axe-playwright (which wraps axe-core for a Playwright-driven page, exactly what the test-runner uses) alongside the Storybook test packages.

npm install --save-dev axe-playwright @storybook/test @storybook/test-runner

What this does: axe-playwright gives you injectAxe and checkA11y, which inject the axe-core script into the running story page and run a scan that throws on violations. @storybook/test supplies the within, userEvent, and expect helpers used inside the play function.

2. Run axe against the story canvas inside the play function

Add a play function that scopes axe to the story’s canvasElement (the root the component renders into) and calls checkA11y. Configure it to throw so a violation fails the story.

// IconButton.stories.tsx — the play function now asserts zero violations
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';
import { injectAxe, checkA11y } from 'axe-playwright';
import { IconButton } from './IconButton';

const meta: Meta<typeof IconButton> = {
  component: IconButton,
  args: { onClick: fn() },
};
export default meta;
type Story = StoryObj<typeof IconButton>;

export const Default: Story = {
  play: async ({ canvasElement }) => {
    // inject axe-core into the running story page
    await injectAxe(canvasElement.ownerDocument.defaultView as unknown as never);
    // scan only the story root; throw if any violation is found
    await checkA11y(canvasElement, undefined, {
      detailedReport: true,
      detailedReportOptions: { html: true },
    });
  },
};

What this does: injectAxe loads the axe-core runtime into the page, and checkA11y(canvasElement, …) runs a scan scoped to just the component. When the icon button has no accessible name, checkA11y throws, and because it runs inside play, the story is marked failed.

Note: axe-playwright’s browser-side helpers are designed to run under the test-runner (which drives the story in a real Playwright page). For scans that must also throw while browsing Storybook interactively, prefer the test-runner postVisit hook in step 4, which has a first-class page handle.

3. Make the fix and watch the assertion flip

Give the button an accessible name. The same play function that failed now passes — that is the proof the assertion is live.

// IconButton.tsx — add an accessible name
export function IconButton({ label, onClick }: { label: string; onClick: () => void }) {
  return (
    <button onClick={onClick} aria-label={label} className="icon-btn">
      <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
        <path d="M4 4 L12 12 M12 4 L4 12" stroke="currentColor" strokeWidth="2" />
      </svg>
    </button>
  );
}
// pass the name from the story args
export const Default: Story = {
  args: { label: 'Close dialog', onClick: fn() },
  play: async ({ canvasElement }) => {
    await injectAxe(canvasElement.ownerDocument.defaultView as unknown as never);
    await checkA11y(canvasElement, undefined, { detailedReport: true });
  },
};

What this does: axe-core’s button-name rule now finds an accessible name via aria-label, reports zero violations, and checkA11y returns without throwing — the story goes green for a real reason.

4. Gate every story through the test-runner’s postVisit hook

For blanket coverage without adding a play function to every story, run axe from the test-runner’s postVisit hook. This scans every story headlessly in CI and fails the run on any violation.

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

const config: TestRunnerConfig = {
  async preVisit(page) {
    await injectAxe(page);
  },
  async postVisit(page, context) {
    // scope axe to the story root the test-runner renders into
    await configureAxe(page, {
      rules: [{ id: 'region', enabled: false }], // components render outside a landmark by design
    });
    await checkA11y(page, '#storybook-root', {
      detailedReport: true,
      detailedReportOptions: { html: true },
    }, false, 'v2');
  },
};

export default config;
# run the full accessibility gate in CI
npx test-storybook --url http://127.0.0.1:6006

What this does: preVisit injects axe before each story renders, and postVisit runs checkA11y scoped to #storybook-root after render. Every story is now scanned, and any violation throws — failing test-storybook and, with it, the CI job.

Verification

Run the test-runner against the broken component (before the step 3 fix) and confirm the violation now fails the run rather than passing silently:

npx test-storybook --url http://127.0.0.1:6006 --failOnConsole

Expected output when the icon button has no accessible name:

 FAIL   src/IconButton.stories.tsx > Default
   Expected the page to have no accessibility violations but received:

   Violation: button-name (Buttons must have discernible text)
     Impact: critical
     WCAG: wcag2a, wcag412
     Nodes: 1
       <button class="icon-btn"> … </button>

 Test Suites: 1 failed, 0 passed
 Tests:       1 failed

After adding aria-label (step 3), the same command passes:

 PASS   src/IconButton.stories.tsx > Default
   ✓ Default (128 ms)

 Test Suites: 1 passed
 Tests:       1 passed

The critical difference from the reproduction: the violation now moves the run from PASS to FAIL. That is the whole point — the accessibility check is enforced, not merely displayed.

Edge cases and caveats

  • Interaction-dependent states must scan after the interaction. A dialog that is accessible when closed can fail contrast or focus-trap rules when open. Put the userEvent steps first and the checkA11y call last inside the same play function, so axe scans the committed post-interaction DOM rather than the initial render.
  • axe-core cannot detect everything. Automated rules catch roughly a third to a half of WCAG issues — missing names, contrast, invalid ARIA. They cannot judge whether alt text is meaningful or whether focus order is logical. Treat a green axe run as a floor, not a certificate, and pair it with manual keyboard and screen-reader passes.
  • Disable rules that fire only because a component renders in isolation. Rules like region and landmark-one-main assume a full page; a single component legitimately renders outside any landmark. Disable those globally in the test-runner config rather than suppressing them per story, so real violations of other rules still surface.

FAQ

Why does the a11y addon panel show violations but my story still passes?

The addon panel runs axe-core for display only — it renders a report but never throws, so nothing fails. To make a story fail you must run axe inside the play function (or the test-runner’s postVisit hook) and assert zero violations. The play function participates in the test lifecycle, so a thrown assertion marks the story as failing in the sidebar and in the test-runner.

Should I run axe in the play function or in the test-runner postVisit hook?

Use the play function when the violation depends on an interaction — an expanded menu, an open dialog, a focused field — because the scan must happen after those steps. Use the test-runner postVisit hook for a blanket static scan of every story with no extra code per story. Many teams do both: postVisit for baseline coverage, play functions for interaction-specific states.

Does running axe-core in the play function slow down Storybook?

A single axe scan adds roughly 50 to 150 milliseconds per story, which is negligible while developing in the browser. In CI the cost is paid once per story in the test-runner, which shards across workers. Scope each scan to the story canvas rather than the whole document so axe only analyses the component under test.