Storybook Accessibility Checks for Data Components

Permalink to "Storybook Accessibility Checks for Data Components"

Design systems document components in Storybook, and Storybook’s accessibility addon runs axe-core against whichever story is open, showing violations in a panel. With the Storybook test runner (or the Vitest-based testing addon in recent versions), the same checks run for every story in CI. For data components — tables, grids, filters, pagination, charts — this is the earliest point accessibility regressions can be caught, before any product composes the component.

The value depends entirely on the stories: a table with one “Default” story is checked in one state. This recipe writes stories per state, reaches interactive states with play functions, and enforces results in CI. It belongs to accessibility testing recipes.

Spec reference

Permalink to "Spec reference"
  • @storybook/addon-a11y runs axe-core on each story and displays violations, passes and incomplete results. Story parameters.a11y accepts config (axe rules), options (axe run options such as runOnly), and in newer versions a test setting ('error' | 'todo' | 'off') that controls whether violations fail automated runs.
  • Play functions run after a story renders, using @storybook/test (Testing Library and userEvent) to interact — the accessibility scan then sees the resulting state.
  • Test runner (@storybook/test-runner) visits every story in a headless browser and can run axe via axe-playwright in its hooks; newer Storybook versions integrate accessibility into the Vitest-based test addon.

Exact configuration names vary by Storybook major version; check your version’s addon documentation.

From story to CI result Flow of Storybook accessibility checking: a story per state renders, a play function reaches the interactive state, axe runs, the panel shows results locally, and the test runner fails CI on violations. From story to CI resultStory per stateloaded, sorted,empty…Play functionkeyboard to reachstateaxe scanaddon-a11yPanellocal feedbackTest runnerfails CI onviolations
The same scan runs in the developer's panel and in CI — local feedback and enforcement from one setup.

When Storybook checks help — and their limits

Permalink to "When Storybook checks help — and their limits"

They help most in a design system, where components are built and documented in isolation. Every documented state gets scanned, and consumers inherit components that pass.

They cannot see composition problems: a table that passes alone and fails when a product wraps it in a card with a duplicate heading, or a filter that is correct alone and unlabelled in the product’s toolbar. Products still need their own page-level scans — see axe scans of grid states with Playwright.

The misapplication to name is disabling rules in the global Storybook preview parameters because a few stories — often deliberately broken “anti-pattern” examples in documentation — fail. Disable per story, with a reason, and mark anti-pattern stories clearly.

Annotated code example

Permalink to "Annotated code example"
// DataTable.stories.jsx
import { expect, userEvent, within } from '@storybook/test';
import { DataTable } from './DataTable';
import { invoices } from './fixtures';

export default {
  title: 'Data/DataTable',
  component: DataTable,
  args: { caption: 'Open invoices', rows: invoices },
  parameters: { a11y: { options: { runOnly: ['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa'] } } },
};

export const Loaded = {};

export const Empty = { args: { rows: [] } };            // empty state is a story, not a prop nobody sets

export const SortedByAmount = {
  play: async ({ canvasElement }) => {
    const c = within(canvasElement);
    const btn = c.getByRole('button', { name: 'Amount' });
    btn.focus();
    await userEvent.keyboard('{Enter}');                 // keyboard, like a user
    await expect(c.getByRole('status')).toHaveTextContent(/Sorted by Amount/);
  },
};

export const EditingCell = {
  play: async ({ canvasElement }) => {
    const c = within(canvasElement);
    c.getByRole('gridcell', { name: '1,280.00' }).focus();
    await userEvent.keyboard('{Enter}');
    await expect(c.getByRole('textbox', { name: /Amount/ })).toHaveFocus();
  },
};

// An anti-pattern example kept for documentation — excluded explicitly
export const AntiPatternDivTable = {
  render: () => <div className="fake-table">…</div>,
  parameters: {
    a11y: { test: 'off' },                                // documented: intentionally inaccessible
    docs: { description: { story: 'Shown as a counter-example. Do not copy.' } },
  },
};
// .storybook/test-runner.js (test-runner + axe-playwright)
import { injectAxe, checkA11y } from 'axe-playwright';
import { getStoryContext } from '@storybook/test-runner';

export default {
  async preVisit(page) { await injectAxe(page); },
  async postVisit(page, context) {
    const story = await getStoryContext(page, context);
    if (story.parameters?.a11y?.test === 'off') return;   // explicit opt-out only
    await checkA11y(page, '#storybook-root', {
      axeOptions: story.parameters?.a11y?.options,
      detailedReport: true,
    });
  },
};

Play functions run before the scan, so the SortedByAmount story is scanned in its sorted state — with aria-sort set and the status message rendered — which a default story never shows.

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"

The a11y addon checks structure; play functions can check keyboard behaviour at the same time, as the examples do with toHaveFocus.

Story Structural scan Behavioural assertion in play
Loaded Headers, names, roles —
Empty Table or empty-state structure Empty message visible
SortedByAmount aria-sort placement Status message after Enter
EditingCell Editor has a name Focus moved into the editor
Keyboard contract story — Arrow keys move focus (see keyboard recipe)
A component's story matrix Mock table of a data table component's stories, the state each represents, and whether each is scanned in CI. A component's story matrixStoryStateScanned in CILoadeddefault datayesEmptyno rowsyesSortedByAmount1after Enter on he…yesEditingCelleditor openyesAntiPatternDivTablecounter-exampleoff, documen…21Play functions reach interactive statesbefore the scan2Opt-outs are per story, visible andexplained — never global
One story per state that changes markup — and an explicit, documented opt-out for counter-examples.

Integration context

Permalink to "Integration context"

Play functions use Testing Library queries; writing them by role and name doubles as an accessibility check — Testing Library role queries for grids. Rule configuration should match the rest of the organisation’s scans, per configuring axe-core rules and handling false positives.

For teams without Storybook, the same per-state approach works with jest-axe or Vitest in unit tests: writing jest-axe tests for data grid components.

One default story versus a story per state Comparison of documenting a data component with a single default story against a story for each meaningful state with play functions. One default story versus a story per state✗ One Default storyScans the loaded state onlyEmpty, sorted, editing never checkedConsumers discover state bugs in productionDocs show one happy path✓ A story per stateLoaded, empty, sorted, editing, errorPlay functions reach interactive statesEvery state scanned in CIDocs show real behaviour
The addon can only check what the stories render.

Gotchas

Permalink to "Gotchas"

Decorators that add chrome. Global decorators that wrap stories in layouts can introduce violations (duplicate landmarks). Scope scans to #storybook-root or the component container.

Colour contrast in themes. Stories render in one theme by default. Add a toolbar global for theme and run the test runner once per theme, or add dark-theme stories for critical components.

Portals. Menus and dialogs rendered into document.body are outside #storybook-root; include them in the scan context for those stories.

Design system notes

Permalink to "Design system notes"

Make “a story per state” part of the component definition of done, with the a11y test set to error for all component stories. Publish the story matrix in each component’s docs so consumers can see exactly which states are guaranteed.

Testing checklist

Permalink to "Testing checklist"

FAQ

Permalink to "FAQ"
Does the Storybook accessibility addon test interactive states?

It scans whatever the story renders. Use play functions to reach interactive states — sorted, editing, menu open — before the scan runs, and write a separate story for each state.

How do I run Storybook accessibility checks in CI?

Use the Storybook test runner with an axe integration in its hooks, or the Vitest-based testing addon in newer Storybook versions, so every story is scanned headlessly and violations fail the build.

How should intentionally inaccessible example stories be handled?

Turn the accessibility test off for that story only, with a clear description saying it is a counter-example. Never disable rules globally to make such stories pass.

Should stories be checked in dark mode as well?

Yes for components with theme-dependent colours. Add a theme global and run the test runner once per theme, or add dark-theme variants of the critical stories, so contrast is checked in both.

Are Storybook checks enough for a design system?

They cover components in isolation. Products composing the components still need page-level scans, keyboard tests and screen reader checks.

Permalink to "Related"

← Back to Accessibility Testing Recipes