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-a11yruns axe-core on each story and displays violations, passes and incomplete results. Storyparameters.a11yacceptsconfig(axerules),options(axe run options such asrunOnly), and in newer versions atestsetting ('error' | 'todo' | 'off') that controls whether violations fail automated runs.- Play functions run after a story renders, using
@storybook/test(Testing Library anduserEvent) 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 viaaxe-playwrightin 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.
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) |
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.
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.
Related
Permalink to "Related"- Testing Library role queries — queries used in play functions
- jest-axe for grid components — the unit-test alternative
- Configuring axe-core rules — shared options