Accessibility-First Design Systems: WCAG 2.2 in Every Component
Building WCAG 2.2 compliance into every component from day one — not as an afterthought. Here's how to structure a React design system that passes audits by default.
Why Accessibility Gets Bolted On Last (And Why That's a Problem)
Honestly, most design systems treat accessibility like a coat of paint — something you apply after the walls are up. You ship 40 components, someone runs an axe audit, and suddenly you've got 200 violations to fix across a library that 12 teams are already using in production.
WCAG 2.2 didn't appear overnight. The spec has been building since 2.0, and 2.2 specifically added criteria around focus appearance (2.4.11 and 2.4.12), dragging alternatives (2.5.7), and target size minimums (2.5.8). These aren't edge cases — they affect standard interactive elements that every component library ships.
The fix isn't a big refactor sprint. It's a mindset shift at the token and component primitive level. If your base Button component fails contrast or has a focus ring that disappears on dark backgrounds, every composed component built on it inherits that failure. Fix it once at the source.
Accessibility-first means your defaults are compliant. You can always let consumers opt out of specific behaviors, but the baseline should pass without anyone thinking about it.
Design Token Architecture That Enforces Contrast
Your color system is the foundation. Before writing a single component, you need a token layer where every foreground/background pair is guaranteed to hit at least 4.5:1 for normal text and 3:1 for large text (WCAG 2.1 criteria 1.4.3). That's not optional — it's the floor.
The practical approach is to define color roles, not raw values. Don't expose gray-700 directly in your component API. Expose text-primary, text-muted, surface-default, surface-elevated. Then in your token definition, you control which gray values map to which roles, and you can enforce contrast ratios at the token generation step.
With Tailwind v4.0.2, you can use CSS custom properties in your @theme block and enforce this systematically. The key is making non-compliant combinations impossible to accidentally reach through your public API. You're not preventing custom overrides — you're just making the happy path the accessible path.
Here's what a minimal compliant token set looks like in a design system's base CSS layer:
``css
@layer base {
:root {
/* Surface tokens */
--color-surface-default: #ffffff;
--color-surface-elevated: #f8f9fa;
--color-surface-overlay: rgba(255, 255, 255, 0.15);
/* Text tokens — all verified against surface-default at 4.5:1+ */
--color-text-primary: #111827; /* ratio: 16.1:1 */
--color-text-secondary: #374151; /* ratio: 10.4:1 */
--color-text-muted: #6b7280; /* ratio: 4.6:1 — barely passes */
--color-text-disabled: #9ca3af; /* ratio: 2.9:1 — disabled state, exempt */
/* Interactive tokens */
--color-interactive-default: #2563eb;
--color-interactive-hover: #1d4ed8;
--color-interactive-focus-ring: #3b82f6;
}
[data-theme="dark"] {
--color-surface-default: #0f172a;
--color-surface-elevated: #1e293b;
--color-text-primary: #f1f5f9; /* ratio: 16.8:1 */
--color-text-secondary: #cbd5e1; /* ratio: 10.1:1 */
--color-text-muted: #94a3b8; /* ratio: 4.7:1 */
}
}
`
Notice text-muted` is right at the edge. That's intentional — it's the dimmest you can go for body copy. If you're building a theme toggle, both light and dark mappings need to maintain those ratios independently.
Focus Management: The Most-Ignored WCAG 2.2 Requirement
WCAG 2.2 criterion 2.4.11 (Focus Appearance, AA) requires that the focus indicator has a minimum area of the perimeter of the unfocused component times 2px CSS pixels, and a contrast ratio of at least 3:1 between focused and unfocused states. Criterion 2.4.12 (AAA) tightens that to a 4.5:1 contrast ratio and an area of at least the perimeter times 2px squared. Most design systems fail 2.4.11.
The common mistake is relying on outline: 2px solid blue. That looks fine on white backgrounds. Put it on a navy card or a gradient header and it disappears completely. You need a focus ring system that adapts to its context — usually a double-ring approach with an inner and outer ring.
Here's a focus ring mixin that handles dark and light contexts without requiring consumers to think about it:
``tsx
// focusRing.ts — reusable focus styles
export const focusRingStyles = [
'focus-visible:outline-none',
'focus-visible:ring-2',
'focus-visible:ring-offset-2',
'focus-visible:ring-blue-500',
// Dark context: swap ring color so it stays visible
'dark:focus-visible:ring-blue-400',
// Offset creates the "double ring" gap for contrast against any background
'focus-visible:ring-offset-white',
'dark:focus-visible:ring-offset-gray-900',
].join(' ');
// Usage in Button component
export function Button({ children, className, ...props }: ButtonProps) {
return (
<button
className={cn(
'px-4 py-2 rounded-md font-medium transition-colors',
'bg-blue-600 text-white hover:bg-blue-700',
'disabled:opacity-50 disabled:cursor-not-allowed',
focusRingStyles,
className
)}
{...props}
>
{children}
</button>
);
}
`
The ring-offset is doing the heavy lifting. It creates a 2px white (or dark) gap between the component edge and the focus ring, which means the ring always has a contrasting background to appear against. The 8px gap in ring-offset-2` (Tailwind's 2 = 2px) is the minimum you want here.
ARIA Patterns in Component Primitives
ARIA is where people either overcorrect or under-implement. Overcorrection looks like adding role="button" to actual <button> elements. Under-implementation looks like a custom dropdown with zero keyboard navigation and no aria-expanded.
The rule is: use semantic HTML first. Every time you reach for a <div> with an onClick, ask yourself if a <button> or <a> would work. They come with keyboard interaction, focus management, and implicit ARIA roles for free. You don't pay that cost with a div.
For complex widgets — dialogs, comboboxes, date pickers, tree views — you need to implement the WAI-ARIA Authoring Practices patterns exactly. Not approximately. The patterns for a combobox with listbox (1.2 pattern) specify which keys do what, which aria- attributes are required on which elements, and how focus moves. Deviate from those patterns and screen reader users get a broken experience.
One thing worth noting: your icon system needs special treatment. Decorative icons get aria-hidden="true". Icons that convey meaning without adjacent text need an aria-label on the parent interactive element. Never put aria-label on the SVG itself — screen reader support for that is inconsistent across browsers.
Target Size and Spacing: WCAG 2.5.8 in Practice
WCAG 2.2 criterion 2.5.8 (Target Size, AA) requires interactive targets to be at least 24x24 CSS pixels. That's the minimum. The AAA version (2.5.5 from 2.1) requires 44x44px. If you're building anything touch-facing, 44x44 is the real target.
The tricky part is inline links in paragraphs — 2.5.8 has an exception for them if the line-height and surrounding content make enlargement impractical. But icon-only buttons, close buttons, checkbox controls, and radio inputs absolutely need to hit that size. A checkbox input with default browser styling is often only 13x13px rendered.
Your spacing system directly affects this. If you're using an 8px base unit (which you should be), your minimum touch target maps to 5.5 × 8 = 44px. Build that into your interactive component defaults and you don't have to think about it per-component.
One approach that works well: wrap small interactive elements in a transparent click target that meets the size requirement, while keeping the visual component at its designed size. CSS padding is usually enough — a small icon button that's 16x16 visually can have 14px padding on all sides to hit 44px without affecting layout.
Testing Accessibility in Your CI Pipeline
Manual testing with a screen reader is irreplaceable — you should do it. But it doesn't scale across a component library. Automated testing catches a meaningful subset of violations before they reach production.
axe-core is the industry standard. @axe-core/react wraps it for React and can run during development. But the real value is in your test suite. With Vitest or Jest plus jest-axe, you can assert that every component variant passes automated accessibility checks:
``tsx
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { Button } from '../Button';
expect.extend(toHaveNoViolations);
describe('Button accessibility', () => {
it('default variant passes axe audit', async () => {
const { container } = render(<Button>Click me</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('disabled state passes axe audit', async () => {
const { container } = render(<Button disabled>Can\'t click</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('icon-only button with aria-label passes axe audit', async () => {
const { container } = render(
<Button aria-label="Close dialog">
<CloseIcon aria-hidden="true" />
</Button>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
``
Run these in CI. A failing axe test should block a merge the same way a failing type check does. That's the culture shift — accessibility violations are bugs, not backlog items.
Automated tools catch roughly 30-40% of real-world accessibility issues. The rest requires manual keyboard testing, screen reader testing (NVDA + Firefox, JAWS + Chrome, VoiceOver + Safari are the main combinations), and actual user testing with people who rely on assistive technology. Plan for all three.
Documenting Accessibility Requirements in Storybook
Your Storybook component library is where consumers learn how to use your components. Accessibility documentation belongs there — not in a separate wiki that nobody reads.
The @storybook/addon-a11y addon adds an Accessibility panel to every story that runs axe-core in real time. That's table stakes. Beyond that, you should document in each component's MDX: which ARIA attributes are required, what keyboard interactions are supported, what the expected screen reader announcement is for each interactive state.
Why does this matter? Because your component can be fully accessible in isolation and still produce an inaccessible UI in context. A modal component might handle focus trapping correctly, but a consumer might nest two modals, or forget to pass an aria-labelledby pointing to the dialog title. The documentation is what bridges that gap.
There's also the question of what you don't expose. Some accessibility behaviors — like focus management on route change in a Next.js app — can't be handled at the component level. Document where your responsibility ends and the consumer's begins. Developers can't implement what they don't know is their job.
The Ongoing Work: WCAG Is a Moving Target
WCAG 3.0 is in development. It's a significant departure from the binary pass/fail model — moving toward a scoring approach that acknowledges that accessibility exists on a spectrum. We don't have a finalized publication date, but following the W3C working drafts is worth doing if you maintain a design system professionally.
What doesn't change: the fundamental goal of making interfaces usable by people with disabilities. Screen readers, keyboard-only navigation, voice control, switch access — these user needs aren't going away, and they're not niche. Roughly 1 in 4 adults in the US has some form of disability. That's not a small audience.
For the design system itself, treat accessibility like you treat type safety. It's not a feature you ship in v2. It's a property of every component, verified at build time, documented explicitly, and treated as a breaking change if it regresses. That's the standard. Anything less and you're shipping tech debt to every team that consumes your library.
Is it more work upfront? Yes. Does it pay off? Absolutely — accessible components are better components. The constraints of keyboard navigation, ARIA semantics, and visible focus states push you toward cleaner HTML structure, more predictable state management, and interfaces that work in more contexts than you designed for.
FAQ
WCAG 2.2 applies to the rendered output — what users experience in a browser. But the most effective place to enforce it is in your component library, because that's where you control the defaults. If your Button component ships with a compliant focus ring and correct ARIA attributes, every app that uses it gets that compliance for free without downstream teams having to think about it.
WCAG 2.1/2.2 Level AA requires 4.5:1 for normal text (below 18pt regular or 14pt bold) and 3:1 for large text. Level AAA requires 7:1 and 4.5:1 respectively. For most design systems, targeting AA is the realistic goal. Disabled states and decorative text are exempt — but be careful about what you classify as 'decorative'.
Use a double-ring technique: ring-2 ring-offset-2 with Tailwind, where ring-offset creates a gap between the component edge and the focus ring. Set the offset color to match the surrounding background (white for light, dark for dark). This ensures the focus ring always has a contrasting backdrop regardless of what it's sitting on. With CSS custom properties, you can expose this as a token that consumers can override per-context.
Yes — Radix UI Primitives and React Aria (from Adobe) are the two most solid options. They implement the WAI-ARIA Authoring Practices patterns for complex widgets (combobox, dialog, tabs, etc.) and handle all the keyboard interaction and focus management. You then layer your own visual styles on top. This is usually faster and more correct than implementing the patterns from scratch.
Axe-core catches roughly 30-40% of real accessibility issues — things it can verify programmatically, like missing alt text, invalid ARIA roles, and contrast failures. It can't test whether a screen reader announces content in a logical order, whether the reading flow makes sense, whether a custom widget is actually operable by keyboard in all edge cases, or whether animations cause problems for people with vestibular disorders. Manual screen reader testing and real user testing cover the rest.
Document the required ARIA relationships explicitly. For example, if you export a Dialog and a DialogTitle, make clear that Dialog needs an aria-labelledby pointing to the DialogTitle's ID, and provide that wiring automatically via context when possible. Use React context to pass IDs between compound component parts so the accessible name relationships are handled automatically by default.