Accessibility Testing in Storybook with addon-a11y and axe-core

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 alt attributes
  • 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-a11y

Add 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-playwright

Then 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.html

Common 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.

Read more

Start now free