Why I care about Accessibility: a short pre-history
Many years ago, at the jam session at friends's place, I met someone with a visual disability. You know standard small talk who works where... But when I mentioned I was a FE dev, the conversation changed instantly. I've been asked with so many questions. Why does one site read perfectly with a screen reader while another is an unnavigable wall of "clickable, clickable, clickable"? Why does a form sometimes announce what a field is for, and sometimes just say "edit text"?
But the question that stuck with me wasn't technical at all. This person asked how to explain to developers that accessibility attributes are so, so important, and how to pass a message to the dev community: please don't ignore us.
I didn't have a good answer that evening. The honest one is uncomfortable: most developers don't ignore accessibility out of malice. They ignore it because nothing in their workflow ever tells them it's broken. Their linter is silent, their tests are green, their PR merges. The people affected are invisible in the feedback loop.
What that conversation left me with is a simple thought I'd put on every developer's wall:
As a dev, you can actually make someone's life better. Don't forget this.
We spend so much time on abstractions (bundle sizes, render cycles, type inference) that it's easy to forget the output is used by people, and for some of them, one aria-label is the difference between finishing a task independently and giving up. Very few professions get such a direct, low-cost way to improve someone's day.
But intent alone doesn't scale: awareness fades, teams change, deadlines press. What scales is wiring that intent into the machinery: the linter, the test suite, the CI gate. When the tooling carries the message, remembering the users who aren't in the room stops depending on any individual's memory.
Here's how we did that, concretely, in a design system.
Why the design system is the leverage point
Accessibility bugs are the most expensive bugs you'll ever ship. Not because they're hard to fix, but because of where they live. A missing aria-label in a product feature affects one screen. The same defect in a design system's Button component affects every screen in every product that consumes it.
That's the bad news. The good news is the inverse is also true: the design system is the single highest-leverage place to enforce accessibility. Fix it once, and every consumer inherits the fix. Test it once, and every consumer inherits the guarantee.
This post walks through a layered, automated accessibility testing strategy we implemented in a component library monorepo: the tooling, the code, the gotchas, and the rollout strategy that kept us from blocking every PR on day one.
Why "just be careful" doesn't work
Without a codified standard, every team makes its own calls on ARIA attributes, color contrast, focus management, and keyboard navigation. The results are predictably inconsistent:
- One team's modal traps focus; another's doesn't.
- One dropdown is a proper
listbox; another is a pile of clickabledivs. - Contrast ratios drift as designers tweak tokens without running the numbers. Beyond the degraded experience for anyone using assistive technology, there's real legal exposure. Depending on your market, you may be subject to the ADA (US), AODA (Ontario), EN 301 549 (EU), or similar legislation, most of which anchor to WCAG as the technical standard. Manual audits catch problems after they ship; automation catches them before they merge.
No single tool covers everything, so we layered four:
| Layer | Tool | When it runs |
|---|---|---|
| Static / lint | eslint-plugin-jsx-a11y |
Every save & CI lint job |
| Unit / integration |
vitest + axe-core
|
Every test run & CI |
| Visual / interaction | @storybook/addon-a11y |
Storybook CI build |
| CI gate | Dedicated workflow | Every PR, blocks merge |
Each layer catches a class of defect the previous one can't. Let's go through them.
Layer 1: Static analysis with eslint-plugin-jsx-a11y
The cheapest place to catch a defect is in the editor, before the code is even saved. eslint-plugin-jsx-a11y statically analyzes JSX and flags a surprising number of issues: missing alt text, invalid ARIA attributes, click handlers on non-interactive elements, and so on.
Use the strict preset. The recommended preset downgrades several rules to warnings, and warnings are noise that everyone learns to ignore.
// eslint.config.js (the idea, not the full config)
rules: {
...jsxA11y.flatConfigs.strict.rules,
// escalate anything strict still leaves as a warning
'jsx-a11y/no-autofocus': 'error',
}
What it catches:
// ❌ Lint error: click handler on a non-interactive element
<div onClick={handleSelect}>Select plan</div>
// ✅ Interactive semantics come for free with the right element
<button type="button" onClick={handleSelect}>Select plan</button>
What it can't catch: anything that only exists at runtime: computed ARIA states, focus order, contrast, or how components compose together. For that, we need the DOM.
Layer 2: Unit tests with axe-core
axe-core is the de facto standard accessibility rules engine. Running it against rendered components in your unit test suite means every component gets scanned on every test run, with zero extra CI plumbing.
The key to making this sustainable is a shared helper so every test looks identical. Ours renders the component, runs axe, and exposes both the scan results and the usual testing-library queries:
// test-utils/renderA11y.tsx (sketch)
export async function renderA11y(ui: ReactElement) {
const result = render(ui);
const axeResults = await axe.run(result.container, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa'] },
});
return { ...result, axeResults };
}
Pair it with an assertion helper (an expectNoViolations(axeResults)) that formats violations into readable strings before asserting. That detail matters more than it looks: when a test fails, the developer should see which rule, which node, and what HTML, not an opaque object diff.
Every component then gets a co-located *.a11y.test.tsx file:
// Switch.a11y.test.tsx (the shape of every such file)
it('has no axe violations', async () => {
const { axeResults } = await renderA11y(<Switch label="Enable notifications" />);
expectNoViolations(axeResults);
});
// ...plus targeted checks: aria state exposed to AT, keyboard operability, focus
Beyond the axe scan, add a few targeted assertions per component: the ARIA state is exposed (aria-checked on a switch), it's reachable by Tab, and it's operable by keyboard (Space/Enter toggles it).
Two practical notes:
Test meaningful states, not just the default render. An accordion that passes axe while collapsed can still fail when expanded. Scan open modals, expanded menus, error states, and disabled states.
Enforce the convention structurally. A rule that lives only in a wiki dies in a wiki. We enforce the co-located test file two ways: it's a line item on the PR contribution checklist, and our component scaffolding (a plop generator) creates the *.a11y.test.tsx file automatically for every new component. The path of least resistance is the compliant path.
The jsdom gotcha: no contrast checking
Here's the trap that catches almost everyone: axe-core running in jsdom cannot detect color-contrast violations. jsdom doesn't implement a CSS layout engine or Canvas, so axe has no way to compute foreground/background colors. The color-contrast rule silently reports "incomplete" rather than failing.
If your only accessibility testing is axe-in-jsdom, you have a false sense of security about the single most common WCAG failure on the web. Contrast requires a real browser, which brings us to layer 3.
Layer 3: Storybook as a browser-based scanner
If you maintain a design system, you almost certainly already have Storybook, and every component already has stories covering its visual states. @storybook/addon-a11y runs axe against each story in a real browser, which means contrast checks actually work.
Wire it globally in preview.tsx so every story is scanned by default. Three decisions matter here, not the boilerplate:
// preview.tsx -> parameters.a11y (the decisions that matter)
{
options: { runOnly: { type: 'tag', values: [/* your WCAG target tags */] } },
test: 'error', // fail the story in test runs, don't just warn
}
That is: pin the run config to your WCAG target so Storybook and unit tests check the same standard, set it once globally rather than per story, and use 'error' so violations fail rather than warn.
Making suppressions traceable
Sometimes a story legitimately needs to skip the scan: a deliberately broken example in documentation, or a known issue with a fix scheduled. The failure mode to avoid is the silent, permanent suppression that nobody remembers adding.
Our rule: you can suppress a scan, but only with a ticket attached. The helper throws at runtime if the ticket reference doesn't match your tracker's key format:
// a11yException (sketch): a suppression is only valid with a ticket attached
export function a11yException({ ticket, reason }: { ticket: string; reason?: string }) {
if (!TICKET_PATTERN.test(ticket)) throw new Error('suppression must reference a tracked issue');
return { a11y: { disable: true, description: `disabled: ${ticket}` } };
}
// usage in a story
parameters: a11yException({ ticket: 'DS-421', reason: 'fails contrast pending token audit' })
This turns suppressions from invisible debt into a queryable backlog: grep for a11yException and you have your remediation list, each entry pointing at a live ticket.
Layer 4: The CI gate, and why we didn't turn it on immediately
The final layer is a dedicated CI workflow that blocks merge on any violation:
# a11y.yml (the essential shape)
on: pull_request
jobs:
storybook-a11y:
steps:
# checkout, node, install...
- run: pnpm --filter storybook build
- run: pnpm --filter storybook test-storybook --ci # axe against every story
Note the division of labor: the axe unit tests already run in the main CI pipeline alongside everything else (turbo test picks them up like any other test file, no special workflow needed). The dedicated workflow exists specifically for the browser-based Storybook scan, because that's the only place contrast and interaction rules can run.
The rollout trap: don't gate before you audit
Here's the strategic decision that saved us weeks of pain. Flipping the CI gate on is trivial; the question is when.
If your color tokens haven't been audited against your contrast target, activating a browser-based contrast gate means every PR that touches a colored component fails, through no fault of the PR author. Nothing kills a quality initiative faster than a gate the team perceives as arbitrary and unfixable at the PR level.
So we phased it:
- Phase 1. Ship the lint rules and axe unit tests. These fail only on defects a developer can actually fix in their PR. Zero contrast rules, zero unfair failures.
- Phase 1b. Ship the Storybook addon in report mode. Violations are visible in the Storybook UI and in CI logs, building awareness without blocking anyone.
- Token audit. Run every color token pair through a contrast checker against your target ratio. Fix the tokens at the token level, so one fix propagates everywhere.
- Phase 2. Only after the audit is green, flip the workflow to blocking. Now every failure is a genuine regression, and the gate has credibility. The sequencing principle generalizes: automated gates should only fail on things the person seeing the failure can fix. Systemic debt gets fixed systemically, then the gate protects the clean state.
Choosing a target: AA required, AAA where it's cheap
WCAG 2.2 AA is the right baseline: it's what most legislation references, and it's achievable for every component. We additionally target AAA color contrast (7:1) and enhanced focus indicators where the design tokens allow, for a simple reason: contrast is decided once, centrally, in the token palette. If your foundation tokens can clear 7:1, every consumer gets AAA contrast for free, and you've built headroom against future tightening of standards. Where AAA would force genuinely worse design trade-offs, AA stands.
A side benefit: accessibility tests audit your types
An unexpected payoff from writing an axe test for every existing component: the process surfaced several prop-type gaps that had been silently reducing type safety: a component whose props type omitted children even though it rendered them, another missing its onChange in the public type, a generic defaulting to an empty record. Writing tests that exercise components the way real consumers do is a forcing function that shakes these out. Log them as they surface; each one is coverage fidelity you're currently losing.
The takeaway
No single tool gives you accessibility coverage. The layers compose:
- Lint catches structural mistakes at author time, for free.
- axe unit tests catch runtime ARIA and semantics defects on every test run, but cannot check contrast in jsdom.
- Storybook + addon-a11y runs axe in a real browser, closing the contrast gap using stories you already have.
- A blocking CI gate makes the standard non-optional, but only flip it on after you've audited the systemic debt it would surface. And thread traceability through all of it: every suppression carries a ticket, every new component scaffolds its own test, every convention is enforced by tooling rather than memory. The design system is where accessibility leverage lives: instrument it once, and every product you ship inherits the guarantee.
I still think about that conversation. As a dev, you can actually make someone's life better. Don't forget this. And so you can't forget it, encode it into the pipeline: then the reminder arrives on every save, every test run, every pull request.
Top comments (0)