Integrate axe-core accessibility audits into your CI pipeline using Storybook a11y addon and Playwright to catch WCAG violations automatically on every pull request.
Integrate axe-core accessibility audits into your CI pipeline using Storybook a11y addon and Playwright to catch WCAG violations automatically on every pull request.
The @storybook/addon-a11y addon runs axe-core on every story in the Storybook UI and CI alike. Install and configure it with your WCAG level requirements:
1// .storybook/main.ts
2import type { StorybookConfig } from '@storybook/react-vite';
3
4export default {
5 addons: [
6 '@storybook/addon-a11y',
7 // other addons...
8 ],
9} satisfies StorybookConfig;1// .storybook/preview.ts
2import type { Preview } from '@storybook/react';
3
4export default {
5 parameters: {
6 a11y: {
7 // Configure axe-core rules globally
8 config: {
9 rules: [
10 // Enable best-practice rules (off by default)
11 { id: 'landmark-one-main', enabled: true },
12 { id: 'region', enabled: true },
13 ],
14 },
15 options: {
16 runOnly: {
17 type: 'tag',
18 values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'],
19 },
20 },
21 },
22 },
23} satisfies Preview;The addon shows violations, passes, and incomplete checks in a panel below each story, letting contributors see accessibility issues before opening a PR.
Run accessibility tests against every story in CI using the @storybook/test-runner with axe integration:
1npm install --save-dev @storybook/test-runner axe-playwright1// .storybook/test-runner.ts
2import type { TestRunnerConfig } from '@storybook/test-runner';
3import { checkA11y, injectAxe } from 'axe-playwright';
4
5export default {
6 async preVisit(page) {
7 await injectAxe(page);
8 },
9 async postVisit(page) {
10 await checkA11y(page, '#storybook-root', {
11 detailedReport: true,
12 detailedReportOptions: { html: true },
13 runOnly: {
14 type: 'tag',
15 values: ['wcag2a', 'wcag2aa', 'wcag21aa'],
16 },
17 });
18 },
19} satisfies TestRunnerConfig;1# .github/workflows/a11y.yml
2jobs:
3 a11y:
4 runs-on: ubuntu-latest
5 steps:
6 - uses: actions/checkout@v4
7 - run: pnpm install --frozen-lockfile
8 - run: pnpm storybook:build --quiet
9 - run: pnpm dlx serve -l 6006 storybook-static &
10 - run: pnpm test-storybook --url http://localhost:6006axe-core catches roughly 30-40% of WCAG issues. Keyboard navigation and screen reader semantics require additional Playwright tests:
1// tests/a11y/button.spec.ts
2import { test, expect } from '@playwright/test';
3import { AxeBuilder } from '@axe-core/playwright';
4
5test.describe('Button accessibility', () => {
6 test('has no axe violations', async ({ page }) => {
7 await page.goto('/iframe.html?id=components-button--primary');
8 const results = await new AxeBuilder({ page })
9 .withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
10 .analyze();
11 expect(results.violations).toEqual([]);
12 });
13
14 test('is keyboard activatable', async ({ page }) => {
15 await page.goto('/iframe.html?id=components-button--primary');
16 const button = page.getByRole('button', { name: /submit/i });
17 // Tab to the button
18 await page.keyboard.press('Tab');
19 await expect(button).toBeFocused();
20 // Activate with Enter
21 await page.keyboard.press('Enter');
22 await expect(page.getByText('Action triggered')).toBeVisible();
23 // Activate with Space
24 await page.keyboard.press('Space');
25 await expect(page.getByText('Action triggered')).toBeVisible();
26 });
27
28 test('has correct ARIA role and label', async ({ page }) => {
29 await page.goto('/iframe.html?id=components-button--icon-only');
30 const button = page.getByRole('button', { name: /close dialog/i });
31 await expect(button).toBeVisible();
32 // Icon-only buttons must have accessible name
33 const ariaLabel = await button.getAttribute('aria-label');
34 expect(ariaLabel).toBeTruthy();
35 });
36});Some axe violations are false positives for specific component contexts. Document suppressions inline with justification — never suppress without explanation:
1// components/Combobox/Combobox.stories.tsx
2import type { Meta, StoryObj } from '@storybook/react';
3import { Combobox } from './Combobox';
4
5export default {
6 title: 'Components/Combobox',
7 component: Combobox,
8 parameters: {
9 a11y: {
10 config: {
11 rules: [
12 {
13 // axe incorrectly flags combobox aria-expanded on the input
14 // when using the ARIA 1.2 combobox pattern.
15 // Reference: https://github.com/dequelabs/axe-core/issues/3853
16 // Verified manually with NVDA 2024.1 + Chrome 124.
17 id: 'aria-required-children',
18 enabled: false,
19 },
20 ],
21 },
22 },
23 },
24} satisfies Meta<typeof Combobox>;Maintain a suppressions register in your design system docs listing every disabled rule, the GitHub issue reference, the browsers and screen readers tested on, and a review date (every 6 months) to re-evaluate whether the suppression is still valid.
Track accessibility quality over time to demonstrate improvement and catch regressions. Establish a baseline by running the full axe audit against the current codebase and recording the violation count per rule. Fail CI if violations increase beyond the baseline:
1// scripts/a11y-baseline.ts
2import { readFileSync, writeFileSync } from 'fs';
3
4interface Baseline {
5 date: string;
6 totalViolations: number;
7 byRule: Record<string, number>;
8}
9
10export function enforceBaseline(
11 currentViolations: Record<string, number>,
12 baselinePath: string
13) {
14 const baseline: Baseline = JSON.parse(
15 readFileSync(baselinePath, 'utf8')
16 );
17
18 const regressions: string[] = [];
19 for (const [rule, count] of Object.entries(currentViolations)) {
20 const baselineCount = baseline.byRule[rule] ?? 0;
21 if (count > baselineCount) {
22 regressions.push(
23 `${rule}: ${baselineCount} -> ${count} (+${count - baselineCount})`
24 );
25 }
26 }
27
28 if (regressions.length > 0) {
29 console.error('Accessibility regressions detected:');
30 regressions.forEach(r => console.error(' ', r));
31 process.exit(1);
32 }
33
34 console.log('No accessibility regressions. Current violations:', currentViolations);
35}Update the baseline file in a dedicated accessibility improvement PR with a PR description explaining which violations were fixed. Never update the baseline to a higher number.
Install @storybook/addon-a11y and run the axe audit against your most complex component (modal, combobox, or data table). Fix the top 3 violations and add them to your CI pipeline this week.
Your progress is saved in this browser, on this device only.