Configuring the Storybook a11y addon for WCAG checks
This page is part of the Accessibility Testing in Isolation section, which covers catching WCAG violations at the component level. The specific situation here: you have installed @storybook/addon-a11y, the panel shows violations, but two things are wrong — those violations never fail CI, and some of the rules fire constantly on components that are perfectly correct in isolation. This page fixes both by configuring the addon precisely and then gating it.
The addon has two jobs that people conflate. It configures which axe-core rules and WCAG tags run, and it displays the results. It does not enforce anything. Enforcement is a separate step through the test-runner — but the test-runner reads exactly the configuration you set on the addon, so getting the configuration right is what makes the gate meaningful.
Problem statement
Out of the box, @storybook/addon-a11y runs the full default axe-core rule set against every story and paints the results in a panel. This produces two failure modes at once.
First, nothing is enforced. The panel is informational. A developer has to open the Accessibility tab, notice the red count, and act on it. In CI there is no panel and no developer, so violations sail through. A component with a colour-contrast failure ships.
Second, the default rules are noisy for isolated components. axe-core was designed to scan whole pages. Rules like region (every piece of content should be inside a landmark), landmark-one-main (a page needs exactly one <main>), and page-has-heading-one assume a full document. A <Button> rendered on its own in Storybook legitimately has none of those, so the panel shows violations that are artefacts of isolation, not real defects. Developers learn to ignore the panel, which guarantees the real violations get ignored too.
The fix is to configure the addon to run the right WCAG rules, silence the ones that only fire because of isolation, and then gate the result so the configuration is actually enforced.
Root cause
The addon and the enforcement are deliberately decoupled. @storybook/addon-a11y owns the axe-core configuration — the rule set, the WCAG tags, per-story overrides — and renders a panel; it never throws, because a panel that failed the story on every violation would break Storybook as a development tool. The enforcement lives in the test-runner, which reads each story’s a11y parameters and runs axe in a hook that does throw. So the noise problem and the “doesn’t fail CI” problem have the same root: the addon is a configuration and display layer, and until you both tune that configuration and wire it into the test-runner, you have a report nobody is required to read.
Minimal reproduction
Install the addon with default configuration and open any single-component story. The panel immediately shows violations that are not real defects:
// .storybook/main.ts — addon registered, no configuration yet
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(ts|tsx)'],
addons: ['@storybook/addon-a11y'], // default rule set, nothing tuned
};
export default config;
Open a Button story and the Accessibility panel reports violations that exist only because the button renders in isolation:
Accessibility — 2 violations
• region — All page content should be contained by landmarks
• landmark-one-main — Document should have one main landmark
Neither is a real problem with the button, and neither fails CI. Developers see two false violations, conclude the panel is unreliable, and stop looking — so when a genuine color-contrast violation appears later, it is lost in the same panel nobody trusts.
Step-by-step fix
1. Register the addon in main.ts
Keep the registration minimal — its only job is to load the panel and the axe-core runtime. All tuning happens in preview.ts and per story.
// .storybook/main.ts
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(ts|tsx)'],
addons: [
'@storybook/addon-essentials',
'@storybook/addon-a11y', // provides the panel + axe-core; config lives in preview.ts
],
framework: { name: '@storybook/react-vite', options: {} },
};
export default config;
What this does: makes the Accessibility panel available in every story and loads axe-core, without yet deciding which rules run. Separating registration from configuration keeps the rule set in one auditable place.
2. Set global axe rules and WCAG tags in preview.ts
Configure parameters.a11y once, globally. Restrict axe to the WCAG 2.1 A and AA tag sets with runOnly, and disable the whole-page rules that fire falsely on isolated components.
// .storybook/preview.ts
import type { Preview } from '@storybook/react';
const preview: Preview = {
parameters: {
a11y: {
// run only the WCAG success criteria the org is held to
config: {
rules: [
// these assume a full page; components render outside landmarks by design
{ id: 'region', enabled: false },
{ id: 'landmark-one-main', enabled: false },
{ id: 'page-has-heading-one', enabled: false },
],
},
options: {
runOnly: {
type: 'tag',
values: ['wcag2a', 'wcag2aa', 'wcag21aa'],
},
},
},
},
};
export default preview;
What this does: runOnly scopes axe to the Level A and AA rules that map to a real compliance target, and the three disabled rules remove the isolation-artefact noise everywhere at once. The panel now shows only violations that are genuine defects in the component under test.
3. Override rules per story where a component genuinely needs it
Global config is the baseline; individual stories can relax or tighten it. Do this sparingly and with a comment explaining why, so an override is never mistaken for a silent suppression.
// Chip.stories.tsx — this design token deliberately ships a low-contrast disabled state
import type { Meta, StoryObj } from '@storybook/react';
import { Chip } from './Chip';
const meta: Meta<typeof Chip> = { component: Chip };
export default meta;
type Story = StoryObj<typeof Chip>;
export const Disabled: Story = {
args: { disabled: true, label: 'Archived' },
parameters: {
a11y: {
config: {
rules: [
// WCAG 1.4.3 exempts disabled controls from contrast; scope the exemption to this story
{ id: 'color-contrast', enabled: false },
],
},
},
},
};
What this does: overrides merge onto the global preview.ts configuration for this one story only. The disabled chip stops reporting a contrast violation that WCAG itself exempts, while every other story still enforces color-contrast in full.
4. Gate the configuration in the test-runner
The panel still only displays. To enforce the exact rules you configured, run the test-runner and have its postVisit hook read each story’s a11y parameters and throw on violations.
// .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);
},
async postVisit(page, context) {
// read the same parameters.a11y the addon panel uses
const storyContext = await getStoryContext(page, context);
const a11y = storyContext.parameters?.a11y;
if (a11y?.disable) return; // respect stories that opt out entirely
await configureAxe(page, {
rules: a11y?.config?.rules, // reuse the per-story + global rule overrides
});
await checkA11y(page, '#storybook-root', {
includedImpacts: ['serious', 'critical'],
detailedReport: true,
});
},
};
export default config;
What this does: getStoryContext pulls the resolved parameters.a11y for each story, so the test-runner enforces precisely the rules configured in preview.ts plus any per-story overrides. checkA11y throws on serious and critical violations, which fails test-storybook and blocks the merge.
Verification
Run the gate in CI against a build that contains a real contrast violation and confirm it fails, while the isolation-artefact rules stay quiet:
# start a static Storybook build and run the gate against it
npx http-server storybook-static --port 6006 --silent &
npx wait-on tcp:6006
npx test-storybook --url http://127.0.0.1:6006
Expected output — the disabled whole-page rules never appear, and a genuine violation fails the run:
FAIL src/Banner.stories.tsx > Warning
Expected no accessibility violations but found 1:
Violation: color-contrast (Elements must meet minimum contrast ratio)
Impact: serious
WCAG: wcag2aa, wcag143
Nodes: 1
<span class="banner__text"> Low stock </span>
Test Suites: 1 failed, 12 passed
Tests: 1 failed, 47 passed
Note what is absent: no region or landmark-one-main noise. The configuration from preview.ts flowed into the test-runner, so the gate enforces exactly the rules you tuned — and only those. To confirm the per-story override works, check that Chip › Disabled passes despite its low-contrast disabled state.
Edge cases and caveats
options.runOnlyandconfig.rulesinteract.runOnlydecides which rules axe considers at all;config.rulestoggles individual rules within that set. Disabling a rule inconfig.rulesthat is not in the activerunOnlytags does nothing. If a disable seems ignored, confirm the rule belongs to an enabled tag first.- A per-story
disable: trueturns off all accessibility checks for that story, not one rule. Reviewers frequently reach forparameters.a11y.disableto silence a single noisy rule and unknowingly drop the entire component out of the gate. Always disable the specific rule id instead, and reservedisable: truefor stories that render nothing scannable (documentation-only stories, for example). - Addon and test-runner must run the same axe-core major version. If
@storybook/addon-a11yandaxe-playwrightresolve different axe-core versions, a rule can pass in the panel and fail in the gate (or vice versa) because rule definitions changed between versions. Pin axe-core with an override inpackage.jsonso both consumers share one version.
FAQ
Why doesn’t the a11y addon fail my CI build when it shows violations?
The addon panel is a display surface — it runs axe-core and shows the results but never throws, so it cannot fail a build on its own. To gate CI you run the Storybook test-runner, which reads each story’s a11y parameters and calls axe in a postVisit hook that throws on violations. The addon configures the rules; the test-runner enforces them.
How do I silence a noisy axe rule without disabling accessibility checks entirely?
Disable the single rule rather than the whole check. Set parameters.a11y.config.rules to an array with that rule id and enabled: false — globally in preview.ts if it is noisy everywhere, or in a single story if only that component triggers it. Rules like region and landmark-one-main assume a full page and fire falsely on isolated components, so they are the usual candidates.
Which WCAG tags should I enable in runOnly?
Start with wcag2a and wcag2aa, which map to the WCAG 2.1 Level A and AA success criteria most organisations are required to meet. Add wcag21aa for the 2.1 additions and best-practice for axe’s non-normative recommendations once the baseline is green. Enabling everything at once on a legacy component set produces an unmanageable flood, so raise the bar incrementally.
Related
- Accessibility Testing in Isolation — the parent section on catching WCAG violations at the component level.
- Running axe-core in Storybook Play Functions — the complementary approach that asserts on accessibility inside a story’s play function for interaction-dependent states.
- Writing Interaction Tests with the Storybook Play Function — the test lifecycle the accessibility gate hooks into.
- Storybook Isolation Workflows — the top-level guide to testing components in isolation with Storybook.