Accessibility Built In
Accessibility Is Not a Feature — It Is a Quality
Here is a harsh truth: most design systems treat accessibility like a checkbox. "We added aria-label, we are accessible now." That is like saying you added a smoke detector so your house is fireproof. Accessibility is a quality standard that touches every layer of your system — from token contrast ratios to keyboard navigation flows to screen reader announcements.
When accessibility is baked into the design system, every product team that uses the system gets it for free. When it is bolted on per-product, it is inconsistent, incomplete, and the first thing cut when deadlines tighten.
Think of accessibility like plumbing in a building. You do not install plumbing in each apartment individually — you build it into the structure so every unit has running water automatically. Your design system is the building. Accessibility is the plumbing. If every component handles focus management, keyboard navigation, and ARIA correctly, then every product built with those components inherits accessibility without extra work.
WCAG 2.2 AA: What You Actually Need to Know
WCAG has over 80 success criteria. Most are straightforward. Here are the ones that actually trip up design system teams:
Color Contrast
- Normal text (under 18pt / 24px): 4.5:1 contrast ratio minimum
- Large text (18pt+ / 24px+ or 14pt+ bold): 3:1 minimum
- Non-text elements (icons, borders, focus indicators): 3:1 minimum
- Disabled elements: exempt from contrast requirements (but should still be visually distinct)
/* Test your tokens */
:root {
--color-text-primary: #111827; /* on white: 17.4:1 — excellent */
--color-text-secondary: #6b7280; /* on white: 4.6:1 — passes AA */
--color-text-muted: #9ca3af; /* on white: 2.9:1 — FAILS AA */
}
That third one is a common trap. Your text-muted token looks fine visually but fails the contrast requirement. Either darken it to at least #737373 (4.5:1) or designate it explicitly as "decorative only, not for readable text."
Focus Indicators
WCAG 2.2 added stricter focus indicator requirements (Success Criterion 2.4.13). The focus indicator must have:
- At least 2px thick outline (or equivalent area)
- 3:1 contrast ratio against the unfocused state
- Not be obscured by other content
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
ARIA Patterns That Matter
The WAI-ARIA Authoring Practices define patterns for common interactive widgets. Let us cover the ones you will build most often.
Dialog (Modal)
The dialog pattern has strict requirements that are easy to get wrong:
function Dialog({ open, onClose, title, children }: DialogProps) {
const dialogRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const previouslyFocused = document.activeElement as HTMLElement;
dialogRef.current?.focus();
return () => {
previouslyFocused?.focus();
};
}, [open]);
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
ref={dialogRef}
tabIndex={-1}
onKeyDown={(e) => {
if (e.key === 'Escape') onClose();
}}
>
<h2 id="dialog-title">{title}</h2>
{children}
</div>
);
}
- 1role='dialog' + aria-modal='true' tells screen readers this is a modal
- 2aria-labelledby points to the dialog title — screen readers announce it on open
- 3Focus moves into the dialog on open and back to the trigger on close
- 4Escape key closes the dialog — this is a user expectation, not optional
- 5Focus must be trapped inside while open — Tab cannot escape to content behind
Tabs
function Tabs({ tabs, activeTab, onTabChange }: TabsProps) {
return (
<div>
<div role="tablist" aria-label="Content tabs">
{tabs.map((tab, index) => (
<button
key={tab.id}
role="tab"
id={`tab-${tab.id}`}
aria-selected={activeTab === tab.id}
aria-controls={`panel-${tab.id}`}
tabIndex={activeTab === tab.id ? 0 : -1}
onClick={() => onTabChange(tab.id)}
onKeyDown={(e) => {
if (e.key === 'ArrowRight') {
const next = tabs[(index + 1) % tabs.length];
onTabChange(next.id);
}
if (e.key === 'ArrowLeft') {
const prev = tabs[(index - 1 + tabs.length) % tabs.length];
onTabChange(prev.id);
}
}}
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab) => (
<div
key={tab.id}
role="tabpanel"
id={`panel-${tab.id}`}
aria-labelledby={`tab-${tab.id}`}
hidden={activeTab !== tab.id}
>
{tab.content}
</div>
))}
</div>
);
}
The critical detail: roving tabindex. Only the active tab has tabIndex={0}. All others have tabIndex={-1}. This means Tab moves focus to the active tab, and Arrow keys move between tabs. Without roving tabindex, a user would have to Tab through every single tab to reach the panel content.
Combobox (Autocomplete)
The combobox is the most complex ARIA pattern. It combines a text input with a listbox popup:
function Combobox({ options, value, onChange, label }: ComboboxProps) {
const [query, setQuery] = useState('');
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const filtered = options.filter(opt =>
opt.label.toLowerCase().includes(query.toLowerCase())
);
const activeDescendant = activeIndex >= 0
? `option-${filtered[activeIndex]?.id}`
: undefined;
return (
<div>
<label id="combo-label">{label}</label>
<input
ref={inputRef}
role="combobox"
aria-expanded={open}
aria-controls="combo-listbox"
aria-activedescendant={activeDescendant}
aria-autocomplete="list"
aria-labelledby="combo-label"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
setActiveIndex(-1);
}}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex(i => Math.min(i + 1, filtered.length - 1));
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex(i => Math.max(i - 1, 0));
}
if (e.key === 'Enter' && activeIndex >= 0) {
onChange(filtered[activeIndex]);
setOpen(false);
}
if (e.key === 'Escape') {
setOpen(false);
}
}}
/>
{open && (
<ul
ref={listRef}
id="combo-listbox"
role="listbox"
aria-labelledby="combo-label"
>
{filtered.map((opt, i) => (
<li
key={opt.id}
id={`option-${opt.id}`}
role="option"
aria-selected={i === activeIndex}
onClick={() => {
onChange(opt);
setOpen(false);
}}
>
{opt.label}
</li>
))}
</ul>
)}
</div>
);
}
The key insight: aria-activedescendant tells the screen reader which option is "focused" without actually moving DOM focus away from the input. The user keeps typing while arrow keys visually highlight options. The screen reader announces the highlighted option because aria-activedescendant points to it.
A common mistake with combobox is moving DOM focus to the listbox options on ArrowDown. This breaks the pattern — the user loses their cursor position in the input and cannot keep typing. The correct approach is virtual focus via aria-activedescendant: DOM focus stays on the input, aria-activedescendant tells assistive technology which option is active, and visual highlighting is handled with CSS (aria-selected styling).
Focus Management Patterns
Focus Trapping
Modals and dialogs must trap focus — Tab should cycle through focusable elements inside the dialog and never escape to the page behind it:
function useFocusTrap(containerRef: RefObject<HTMLElement | null>) {
useEffect(() => {
const container = containerRef.current;
if (!container) return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key !== 'Tab') return;
const focusable = container!.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), ' +
'textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
container.addEventListener('keydown', handleKeyDown);
return () => container.removeEventListener('keydown', handleKeyDown);
}, [containerRef]);
}
Focus Restoration
When a dialog closes, focus must return to the element that opened it:
function useDialogFocus(open: boolean) {
const triggerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (open) {
triggerRef.current = document.activeElement as HTMLElement;
} else {
triggerRef.current?.focus();
}
}, [open]);
}
Roving Tabindex
For composite widgets (tab lists, toolbars, menus), only one item is in the tab order at a time:
function useRovingTabindex(items: string[], activeId: string) {
return items.map(id => ({
tabIndex: id === activeId ? 0 : -1,
onKeyDown: (e: React.KeyboardEvent) => {
const currentIndex = items.indexOf(id);
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
const next = items[(currentIndex + 1) % items.length];
document.getElementById(next)?.focus();
}
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
const prev = items[(currentIndex - 1 + items.length) % items.length];
document.getElementById(prev)?.focus();
}
},
}));
}
Reduced Motion
Some users experience motion sickness, vertigo, or seizures from animations. The prefers-reduced-motion media query lets you respect their preference:
/* Default: animations enabled */
.modal-overlay {
animation: fadeIn 200ms ease-out;
}
.drawer {
transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1);
}
/* Reduced motion: remove or simplify animations */
@media (prefers-reduced-motion: reduce) {
.modal-overlay {
animation: none;
}
.drawer {
transition: opacity 100ms linear;
}
}
The best practice is a global reduction that catches everything:
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
Setting animation-duration: 0ms prevents animationend events from firing, which can break JavaScript that depends on them. Setting 0.01ms is effectively instant but still fires completion events.
Live Regions for Dynamic Content
When content updates without a page reload (toast notifications, form validation, loading states), screen readers need to be told something changed. ARIA live regions handle this:
function Toast({ message }: { message: string }) {
return (
<div
role="status"
aria-live="polite"
aria-atomic="true"
>
{message}
</div>
);
}
function ErrorMessage({ error }: { error: string }) {
return (
<div
role="alert"
aria-live="assertive"
>
{error}
</div>
);
}
aria-live="polite"— announced when the screen reader finishes its current speecharia-live="assertive"— interrupts current speech immediately (use sparingly)role="status"— implicitaria-live="polite"role="alert"— implicitaria-live="assertive"
Automated Testing with axe-core
Manual accessibility testing does not scale. axe-core catches 30-50% of WCAG issues automatically — missing alt text, insufficient contrast, missing ARIA attributes, invalid roles.
import { axe, toHaveNoViolations } from 'jest-axe';
import { render } from '@testing-library/react';
expect.extend(toHaveNoViolations);
test('Button is accessible', async () => {
const { container } = render(<Button>Submit</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
test('Dialog is accessible when open', async () => {
const { container } = render(
<Dialog open onClose={() => {}} title="Confirm">
<p>Are you sure?</p>
<Button>Yes</Button>
</Dialog>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
Run axe-core in your CI pipeline for every component. It will not catch keyboard navigation or focus management issues — those require manual testing — but it catches the low-hanging fruit consistently.
| What developers do | What they should do |
|---|---|
| Using div with onClick instead of button for interactive elements Divs have no keyboard support (no Enter/Space activation), no focus by default, no implicit ARIA role. You would have to add role, tabIndex, and keyboard handlers manually — just use a button | Use semantic HTML: button for actions, a for navigation, input for data entry |
| Adding aria-label to elements that already have visible text aria-label overrides visible text for screen readers, creating a disconnect between what sighted and non-sighted users perceive. If visible text exists, reference it with aria-labelledby | Only use aria-label when there is no visible text — use aria-labelledby to point to existing text |
| Hiding the default focus outline with outline: none without providing an alternative Removing focus outlines makes keyboard navigation impossible — users cannot see where they are on the page. Always replace, never just remove | Replace the outline with a visible custom focus indicator using :focus-visible |
| Testing accessibility only with automated tools Automated tools catch about 30-50% of issues. They cannot test keyboard flow, focus order, screen reader announcements, or whether the experience makes sense to a non-sighted user | Automated tests (axe-core) plus manual keyboard testing plus screen reader testing (VoiceOver/NVDA) |
Screen Reader Testing Checklist
Before shipping any interactive component, test with at least one screen reader:
VoiceOver (macOS): Cmd+F5 to enable. Use VO+Right Arrow to navigate, VO+Space to activate.
- Can you navigate to every interactive element?
- Is the element's role announced correctly (button, link, tab, menu item)?
- Is the element's name/label announced (what does it do)?
- Is the element's state announced (expanded/collapsed, selected, checked)?
- When state changes, is the change announced?
- For dialogs: is the title announced on open?
- For live regions: are dynamic updates announced?
NVDA (Windows): Free download. Use Tab, arrow keys, and NVDA+Space.
If your component passes VoiceOver and NVDA, it will likely work with JAWS and other screen readers.