Back to Design Systems
Topic 7 of 8
Accessibility in Design Systems
Build inclusive components with WCAG compliance, ARIA patterns, and keyboard navigation
Why Accessibility is Non-Negotiable
Design systems are the perfect place to bake in accessibility. When your base components are accessible, every product built on them inherits that accessibility. Get it right once, benefit everywhere.
āæ WCAG 2.1 Quick Reference
Perceivable
Text alternatives, captions, color contrast
Operable
Keyboard access, no seizures, navigable
Understandable
Readable, predictable, input assistance
Robust
Compatible with assistive technologies
Color Contrast Requirements
// WCAG 2.1 Contrast Requirements:
// - AA: 4.5:1 for normal text, 3:1 for large text (18px+ or 14px bold)
// - AAA: 7:1 for normal text, 4.5:1 for large text
// Use colord library for checking contrast
import { colord, extend } from 'colord';
import a11yPlugin from 'colord/plugins/a11y';
extend([a11yPlugin]);
function checkContrast(foreground: string, background: string) {
const ratio = colord(foreground).contrast(background);
return {
ratio: ratio.toFixed(2),
passesAA: ratio >= 4.5,
passesAALarge: ratio >= 3,
passesAAA: ratio >= 7,
};
}
// Test your design tokens
checkContrast('#ffffff', '#3b82f6'); // { ratio: '4.68', passesAA: true }
checkContrast('#6b7280', '#ffffff'); // { ratio: '4.69', passesAA: true }
// Tools:
// - WebAIM Contrast Checker: https://webaim.org/resources/contrastchecker/
// - Stark (Figma plugin): https://www.getstark.co/
Keyboard Navigation
// Every interactive element must be keyboard accessible
// Use semantic HTML first, add custom keyboard handlers when needed
import { useCallback, KeyboardEvent } from 'react';
// Custom hook for roving tabindex (menus, toolbars)
function useRovingTabindex<T extends HTMLElement>(items: T[]) {
const [focusedIndex, setFocusedIndex] = useState(0);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
case 'ArrowRight':
e.preventDefault();
setFocusedIndex((i) => (i + 1) % items.length);
break;
case 'ArrowUp':
case 'ArrowLeft':
e.preventDefault();
setFocusedIndex((i) => (i - 1 + items.length) % items.length);
break;
case 'Home':
e.preventDefault();
setFocusedIndex(0);
break;
case 'End':
e.preventDefault();
setFocusedIndex(items.length - 1);
break;
}
}, [items.length]);
return { focusedIndex, handleKeyDown };
}
// Accessible dropdown example
function Dropdown({ options, onSelect }) {
const [isOpen, setIsOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
return (
<div>
<button
ref={buttonRef}
aria-haspopup="listbox"
aria-expanded={isOpen}
onClick={() => setIsOpen(!isOpen)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setIsOpen(false);
buttonRef.current?.focus();
}
}}
>
Select option
</button>
{isOpen && (
<ul role="listbox" aria-label="Options">
{options.map((opt, i) => (
<li
key={opt.value}
role="option"
tabIndex={0}
onClick={() => onSelect(opt)}
onKeyDown={(e) => e.key === 'Enter' && onSelect(opt)}
>
{opt.label}
</li>
))}
</ul>
)}
</div>
);
}
ARIA Patterns
// Use ARIA only when HTML semantics aren't enough
// "No ARIA is better than bad ARIA"
// ā Bad: Using ARIA when HTML works
<div role="button" tabindex="0" onclick="...">Click me</div>
// ā
Good: Use native HTML
<button onclick="...">Click me</button>
// Common ARIA patterns for design systems:
// 1. Modal Dialog
<div
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
aria-describedby="dialog-description"
>
<h2 id="dialog-title">Delete Item</h2>
<p id="dialog-description">Are you sure you want to delete this?</p>
<button>Cancel</button>
<button>Delete</button>
</div>
// 2. Tabs
<div role="tablist" aria-label="Settings tabs">
<button role="tab" aria-selected="true" aria-controls="panel-1">General</button>
<button role="tab" aria-selected="false" aria-controls="panel-2">Privacy</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
General settings content
</div>
// 3. Alert / Toast
<div role="alert" aria-live="polite">
Your changes have been saved.
</div>
// 4. Loading state
<button disabled aria-busy="true">
<span className="spinner" aria-hidden="true" />
Loading...
</button>
// Reference: WAI-ARIA Authoring Practices
// https://www.w3.org/WAI/ARIA/apg/patterns/
Focus Management
// useFocusTrap - keep focus inside modals/dialogs
import { useEffect, useRef } from 'react';
function useFocusTrap<T extends HTMLElement>() {
const containerRef = useRef<T>(null);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const focusableElements = container.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
// Focus first element on mount
firstElement?.focus();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
};
container.addEventListener('keydown', handleKeyDown);
return () => container.removeEventListener('keydown', handleKeyDown);
}, []);
return containerRef;
}
// Usage in Dialog component
function Dialog({ isOpen, onClose, children }) {
const dialogRef = useFocusTrap<HTMLDivElement>();
const previouslyFocused = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
previouslyFocused.current = document.activeElement as HTMLElement;
} else {
previouslyFocused.current?.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div ref={dialogRef} role="dialog" aria-modal="true">
{children}
</div>
);
}
Screen Reader Testing
// Testing with screen readers is essential!
// macOS: VoiceOver (built-in)
// - Cmd + F5 to enable
// - Use VO keys: Ctrl + Option
// Windows: NVDA (free)
// - Download from https://www.nvaccess.org/
// - Insert key is the NVDA modifier
// Automated testing with axe-core
npm install @axe-core/react
// Setup in development
import React from 'react';
import ReactDOM from 'react-dom';
if (process.env.NODE_ENV !== 'production') {
import('@axe-core/react').then((axe) => {
axe.default(React, ReactDOM, 1000);
// Reports accessibility issues to console
});
}
// Storybook accessibility addon
npm install @storybook/addon-a11y
// .storybook/main.js
module.exports = {
addons: ['@storybook/addon-a11y'],
};
// Run axe on every story automatically!
// jest-axe for unit tests
npm install jest-axe
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('Button is accessible', async () => {
const { container } = render(<Button>Click me</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
Accessible Design System Libraries
Radix UI
Unstyled, accessible primitives with full keyboard support
React Aria (Adobe)
Hooks for accessible UI primitives, extremely thorough
Headless UI
Unstyled, accessible components from Tailwind Labs
Downshift
Accessible autocomplete/dropdown primitives
ā Accessibility Checklist
- ā All interactive elements are keyboard accessible
- ā Focus states are visible and clear
- ā Color contrast meets WCAG AA (4.5:1)
- ā Images have alt text, decorative images use alt=""
- ā Form inputs have associated labels
- ā Error messages are announced to screen readers
- ā Page has proper heading hierarchy (h1 ā h2 ā h3)
- ā Focus is trapped in modals/dialogs
- ā Skip links provided for navigation