Accessibility Testing in Storybook with addon-a11y and axe-core
Accessibility bugs are expensive to fix late. A missing ARIA label, a button that cannot receive keyboard focus, or insufficient color contrast ratio — these are straightforward to catch during component development but painful to remediate after a product has shipped. Storybook's @storybook/addon-a11y brings axe-core accessibility checks directly into your component development workflow, making it trivial to identify violations the moment a component is written.
This guide covers the full setup: configuring the addon, writing rules, automating checks in CI via the test runner, and using play functions to verify ARIA attributes in interactive components.
What axe-core Tests
The addon wraps axe-core, the same accessibility engine used in browser extensions like the Axe DevTools. It tests against WCAG 2.0 and 2.1 rules at levels A and AA, including:
- Color contrast — text must have a minimum contrast ratio against its background
- Image alt text — images require descriptive
altattributes - Form labels — every input must have an associated label
- Button and link names — interactive elements must have accessible names
- Keyboard focus — interactive elements must be reachable via keyboard
- Landmark regions — pages and sections should use proper landmark elements
- ARIA usage — ARIA attributes must be used correctly and on appropriate elements
- Heading hierarchy — heading levels should not skip (h1 → h3 without h2)
Catching these in Storybook means catching them in isolation, on individual components, before they compound across your application.
Installation
npm install --save-dev @storybook/addon-a11yAdd it to your Storybook config:
// .storybook/main.ts
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
addons: [
'@storybook/addon-essentials',
'@storybook/addon-a11y', // Add this
],
};
export default config;That's the complete installation. Restart Storybook and you'll see an "Accessibility" panel at the bottom of the UI. Navigate to any story and it will automatically run axe-core against the rendered output, displaying violations, passes, and incomplete checks.
Understanding the Panel Output
The addon panel shows three categories:
Violations — rules that have definitely failed. These are real accessibility problems you must fix. Each violation links to Deque's documentation explaining why it matters and how to fix it.
Passes — rules that were checked and passed. Useful for verifying your fixes worked.
Incomplete — rules where axe-core found something to flag but couldn't determine if it's a violation without more context. These need human review.
Pay attention to the violation severity levels. critical violations (like buttons with no accessible name) render components unusable for screen reader users. serious violations (like insufficient contrast) significantly impair usability. Address critical and serious violations first.
Configuring Rules Globally
Some rules are not relevant to your application, or you might want to intentionally relax certain checks for a known reason. Configure globally in .storybook/preview.ts:
// .storybook/preview.ts
const preview = {
parameters: {
a11y: {
config: {
rules: [
{
// Disable color contrast checking globally
// (you might do this if your design system enforces
// contrast in a way axe can't detect)
id: 'color-contrast',
enabled: false,
},
{
// Demote a violation to a best-practice suggestion
id: 'region',
reviewOnFail: true,
},
],
},
// Optional: scope checks to a specific element
// By default it checks the entire document
element: '#storybook-root',
},
},
};
export default preview;Be conservative about disabling rules globally. Each disabled rule is a class of accessibility bugs you'll stop catching. If you must disable something, add a comment explaining why.
Configuring Rules Per Story
Per-story configuration overrides the global settings:
// Modal.stories.ts
export const DialogModal: Story = {
parameters: {
a11y: {
config: {
rules: [
{
// Dialogs need special treatment — disable the
// scrollable-region-focusable rule for this component
// because the scroll behavior is handled by the portal
id: 'scrollable-region-focusable',
enabled: false,
},
],
},
},
},
};To disable accessibility checking entirely for a story (use sparingly, and document why):
export const LegacyWidget: Story = {
parameters: {
a11y: {
disable: true, // Skip axe checks for this story
},
},
};Writing Play Functions that Verify ARIA
Static axe-core checks catch many violations, but interactive components need interaction-time verification. A dropdown menu that adds aria-expanded="true" when opened, or a form field that adds aria-invalid="true" on validation error — these states only exist after user interaction.
// Dropdown.stories.ts
import { expect, userEvent, within } from '@storybook/test';
export const OpenDropdown: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const trigger = canvas.getByRole('button', { name: 'Select option' });
// Verify initial ARIA state
expect(trigger).toHaveAttribute('aria-expanded', 'false');
expect(trigger).toHaveAttribute('aria-haspopup', 'listbox');
// Open the dropdown
await userEvent.click(trigger);
// Verify ARIA state after opening
expect(trigger).toHaveAttribute('aria-expanded', 'true');
// Verify the listbox is present and correctly associated
const listbox = canvas.getByRole('listbox');
expect(listbox).toBeVisible();
expect(trigger).toHaveAttribute(
'aria-controls',
listbox.getAttribute('id')
);
// Verify first option is focused or selectable
const firstOption = canvas.getAllByRole('option')[0];
expect(firstOption).toHaveAttribute('aria-selected', 'false');
},
};// FormField.stories.ts
export const ValidationState: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByRole('textbox', { name: 'Email address' });
const submitButton = canvas.getByRole('button', { name: 'Submit' });
// Verify initial state has no error attributes
expect(input).not.toHaveAttribute('aria-invalid');
expect(input).not.toHaveAttribute('aria-describedby');
// Submit empty form to trigger validation
await userEvent.click(submitButton);
// Verify error state ARIA attributes
expect(input).toHaveAttribute('aria-invalid', 'true');
// Verify error message is associated via aria-describedby
const errorId = input.getAttribute('aria-describedby');
expect(errorId).toBeTruthy();
const errorMessage = document.getElementById(errorId!);
expect(errorMessage).toBeInTheDocument();
expect(errorMessage).toHaveTextContent('Email is required');
},
};These play functions verify the ARIA contract, not just the visual appearance. A component can look correct while having broken ARIA relationships that screen readers cannot follow.
Automating Accessibility Checks in CI
The real value comes when a11y checks run automatically on every pull request. Use the test runner's postVisit hook to run axe-core checks after every story:
First, install the required package:
npm install --save-dev axe-playwrightThen configure the test runner:
// .storybook/test-runner.ts
import type { TestRunnerConfig } from '@storybook/test-runner';
import { checkA11y, injectAxe } from 'axe-playwright';
import { getStoryContext } from '@storybook/test-runner';
const config: TestRunnerConfig = {
async preVisit(page) {
await injectAxe(page);
},
async postVisit(page, context) {
const storyContext = await getStoryContext(page, context);
// Respect per-story a11y disable
if (storyContext.parameters?.a11y?.disable) {
return;
}
await checkA11y(page, '#storybook-root', {
detailedReport: true,
detailedReportOptions: {
html: true,
},
// Pass through any per-story axe config
axeOptions: storyContext.parameters?.a11y?.config,
});
},
};
export default config;Now every story is an accessibility test. The CI run will fail if any story has axe-core violations, and the failure message will include the violation details.
GitHub Actions Integration
# .github/workflows/a11y-tests.yml
name: Accessibility Tests
on:
pull_request:
branches: [main]
jobs:
a11y:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Build Storybook
run: npm run build-storybook -- --quiet
- name: Serve and test accessibility
run: |
npx serve storybook-static --port 6006 &
npx wait-on http://localhost:6006
npm run test-storybook -- --url http://localhost:6006
- name: Upload a11y report
if: failure()
uses: actions/upload-artifact@v4
with:
name: a11y-report
path: accessibility-report.htmlCommon Violations and How to Fix Them
Buttons Without Accessible Names
Violation: button-name — Button does not have an accessible name.
This happens with icon-only buttons:
// Wrong
<button onClick={handleClose}>
<XIcon />
</button>
// Correct — option 1: aria-label
<button onClick={handleClose} aria-label="Close dialog">
<XIcon aria-hidden="true" />
</button>
// Correct — option 2: visually hidden text
<button onClick={handleClose}>
<XIcon aria-hidden="true" />
<span className="sr-only">Close dialog</span>
</button>Note the aria-hidden="true" on the icon — without it, screen readers will try to read the SVG's internal content.
Images Without Alt Text
Violation: image-alt — Image elements do not have [alt] attributes.
// Wrong
<img src={userAvatar} />
// Correct — meaningful image
<img src={userAvatar} alt={`Profile photo of ${user.name}`} />
// Correct — decorative image (no information conveyed)
<img src={decorativeDivider} alt="" />Empty string alt="" is valid and correct for decorative images. It tells screen readers to skip the image entirely.
Insufficient Color Contrast
Violation: color-contrast — Elements must have sufficient color contrast.
WCAG AA requires 4.5:1 contrast ratio for normal text and 3:1 for large text (18pt or 14pt bold).
/* Check with a tool like https://webaim.org/resources/contrastchecker/ */
/* Wrong — light gray on white */
.hint-text {
color: #aaaaaa; /* contrast ratio ~2.3:1 against white */
}
/* Correct — darker gray */
.hint-text {
color: #767676; /* contrast ratio ~4.5:1 against white — barely passes */
}
/* Better — comfortable to read */
.hint-text {
color: #595959; /* contrast ratio ~7:1 against white */
}Form Inputs Without Labels
Violation: label — Form elements must have labels.
// Wrong — placeholder is not a label
<input type="email" placeholder="Enter your email" />
// Correct — explicit label
<label htmlFor="email">Email address</label>
<input id="email" type="email" />
// Correct — aria-label when visual label is not desired
<input
type="search"
aria-label="Search products"
placeholder="Search..."
/>
// Correct — aria-labelledby when label text exists elsewhere
<h2 id="billing-heading">Billing Address</h2>
<input aria-labelledby="billing-heading" type="text" />Keyboard Navigation Issues
Violation: tabindex — Elements with positive tabindex values are present.
Positive tabindex values (tabindex="2", tabindex="5") create a custom tab order that almost always creates confusion. Use tabindex="0" to make a non-interactive element focusable, and tabindex="-1" to make it programmically focusable without adding it to the tab order.
// Wrong — disrupts natural tab order
<div tabIndex={3} onClick={handleClick}>Custom Button</div>
// Correct — use a real button
<button onClick={handleClick}>Custom Button</button>
// If you must use a div (don't), at least give it a role
<div
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={(e) => e.key === 'Enter' && handleClick()}
>
Custom Button
</div>WCAG 2.1 Compliance Tracking
axe-core covers a subset of WCAG rules — it catches what it can determine programmatically, but some WCAG success criteria require human judgment. Track your compliance status by story using Storybook's parameter system:
// Button.stories.ts
const meta: Meta<typeof Button> = {
component: Button,
parameters: {
a11y: {
// Document manually-verified WCAG criteria
// that axe cannot check automatically
manual: [
{
criterion: '2.4.3', // Focus Order
status: 'pass',
notes: 'Focus order follows visual reading order',
},
{
criterion: '2.5.3', // Label in Name
status: 'pass',
notes: 'Button label matches visible text',
},
],
},
},
};This won't be enforced by tooling, but it creates documentation of your compliance decisions that lives alongside the component.
Building a Culture of Accessible Components
Tooling catches the mechanical violations. The harder part is building habits: writing accessible components from the start, reviewing ARIA usage in code review, and treating accessibility failures with the same urgency as functional bugs.
Storybook helps by making violations visible during development, before code review, before QA. The moment you write an icon button without an aria-label, the panel turns red. That immediate feedback loop changes behavior.
For production applications, accessibility is also a production concern — screen readers and keyboard users interact with your deployed site, not your Storybook. HelpMeTest extends this coverage to your live application with continuous automated monitoring, verifying that the accessibility standards you enforce in Storybook are maintained as your application evolves in production.