Accessibility Implementation Best Practices
1. WCAG 2.1 AA Compliance Implementation
| Level | Requirements | Target Audience | Legal Status |
|---|---|---|---|
| WCAG 2.1 A | 30 criteria - basic accessibility | Minimum for most disabilities | Baseline legal requirement |
| WCAG 2.1 AA TARGET | 50 criteria - enhanced accessibility | Most users with disabilities | Required by ADA, Section 508, EU |
| WCAG 2.1 AAA | 78 criteria - highest accessibility | Specialized needs | Recommended but not required |
| WCAG 2.2 NEW | 9 new criteria (mostly AA) | Mobile, cognitive disabilities | Emerging standard (2023) |
Example: WCAG 2.1 AA Implementation
// 1.1.1 Non-text Content (A)
// All images must have alt text
<img src="logo.png" alt="Company Logo" />
<img src="decorative.png" alt="" /> {/* Decorative images */}
// 1.3.1 Info and Relationships (A)
// Use semantic HTML
<header>
<nav>
<ul>
<li><a href="/">Home</a></li>
</ul>
</nav>
</header>
<main>
<article>
<h1>Page Title</h1>
<section>
<h2>Section Title</h2>
</section>
</article>
</main>
// 1.4.3 Contrast Minimum (AA)
// Minimum contrast ratio 4.5:1 for normal text, 3:1 for large text
.text {
color: #333; /* On white background = 12.6:1 ratio ✅ */
background: #fff;
}
.button {
color: #fff;
background: #0066cc; /* 4.5:1 ratio ✅ */
}
// 1.4.5 Images of Text (AA)
// Use actual text, not images of text (unless logo/brand)
<h1>Welcome</h1> {/* Good */}
<img src="welcome-text.png" alt="Welcome" /> {/* Avoid */}
// 1.4.10 Reflow (AA)
// Content reflows at 320px without horizontal scrolling
@media (max-width: 320px) {
.content {
width: 100%;
overflow-x: hidden;
}
}
// 1.4.11 Non-text Contrast (AA)
// UI components and graphical objects need 3:1 contrast
.button {
border: 2px solid #767676; /* 3:1 contrast against white ✅ */
}
// 2.1.1 Keyboard (A)
// All functionality available via keyboard
<button onClick={handleClick} onKeyDown={handleKeyDown}>
Click me
</button>
// Custom interactive elements
<div
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleClick();
}
}}
>
Custom Button
</div>
// 2.1.2 No Keyboard Trap (A)
// Users can navigate away using keyboard only
function Modal({ onClose }) {
useEffect(() => {
const handleEscape = (e) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [onClose]);
return (
<div role="dialog" aria-modal="true">
<button onClick={onClose}>Close</button>
{/* Modal content */}
</div>
);
}
// 2.4.1 Bypass Blocks (A)
// Skip navigation link
<a href="#main-content" className="skip-link">
Skip to main content
</a>
<main id="main-content">
{/* Content */}
</main>
.skip-link {
position: absolute;
top: -40px;
left: 0;
padding: 8px;
background: #000;
color: #fff;
}
.skip-link:focus {
top: 0;
}
// 2.4.3 Focus Order (A)
// Logical focus order using tabindex
<div>
<button tabIndex={0}>First</button>
<button tabIndex={0}>Second</button>
<button tabIndex={0}>Third</button>
</div>
// 2.4.7 Focus Visible (AA)
// Visible focus indicator
button:focus {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
// 3.2.1 On Focus (A)
// No context change on focus alone
<input
type="text"
onFocus={() => console.log('Focused')} {/* OK */}
onFocus={() => submitForm()} {/* BAD - unexpected */}
/>
// 3.2.2 On Input (A)
// No context change on input alone
<input
type="text"
onChange={(e) => setValue(e.target.value)} {/* OK */}
onChange={() => navigateAway()} {/* BAD - unexpected */}
/>
// 3.3.1 Error Identification (A)
// Clearly identify input errors
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<span id="email-error" role="alert" className="error">
{errors.email}
</span>
)}
</form>
// 3.3.2 Labels or Instructions (A)
// Label all form inputs
<label htmlFor="username">Username</label>
<input id="username" type="text" required />
// Or use aria-label
<input type="search" aria-label="Search articles" />
// 4.1.2 Name, Role, Value (A)
// Custom components need ARIA
<div
role="checkbox"
aria-checked={isChecked}
aria-labelledby="checkbox-label"
tabIndex={0}
onClick={toggle}
onKeyDown={(e) => {
if (e.key === ' ') toggle();
}}
>
<span id="checkbox-label">Accept terms</span>
</div>
// React accessibility hook
function useA11yAnnouncement() {
const [announcement, setAnnouncement] = useState('');
return {
announce: (message) => {
setAnnouncement(message);
setTimeout(() => setAnnouncement(''), 100);
},
LiveRegion: () => (
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{announcement}
</div>
)
};
}
// Screen reader only utility
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
// Accessible form validation
function AccessibleForm() {
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
return (
<form noValidate>
<div>
<label htmlFor="email">
Email <span aria-label="required">*</span>
</label>
<input
id="email"
type="email"
required
aria-required="true"
aria-invalid={touched.email && !!errors.email}
aria-describedby={
touched.email && errors.email ? 'email-error' : undefined
}
/>
{touched.email && errors.email && (
<span id="email-error" role="alert">
{errors.email}
</span>
)}
</div>
</form>
);
}
// WCAG 2.2 new criteria
// 2.4.11 Focus Not Obscured (Minimum) (AA)
// Ensure focused element is not fully hidden
.modal {
z-index: 1000;
/* Ensure focus is visible within modal */
}
// 3.2.6 Consistent Help (A)
// Help mechanism in same location on all pages
<header>
<a href="/help" aria-label="Get help">Help</a>
</header>
Statistics: 26% of US adults have a disability. 1 in 4 potential
customers. Accessible sites have better SEO, mobile UX, and legal protection.
2. Screen Reader Testing NVDA JAWS
| Screen Reader | Platform | Market Share | Cost |
|---|---|---|---|
| JAWS | Windows | ~40% | $1,000+ (most feature-rich) |
| NVDA | Windows | ~30% | Free (open source) |
| VoiceOver | macOS, iOS | ~15% | Free (built-in) |
| TalkBack | Android | ~10% | Free (built-in) |
| Narrator | Windows | ~5% | Free (built-in) |
Example: Screen Reader Testing
// Screen reader keyboard shortcuts
// NVDA (Windows)
NVDA + Space // Toggle browse/focus mode
NVDA + Down Arrow // Read next item
NVDA + T // Next table
NVDA + H // Next heading
NVDA + K // Next link
NVDA + B // Next button
NVDA + F // Next form field
NVDA + Insert + F7 // Elements list
// JAWS (Windows)
Insert + Down Arrow // Say all (read from cursor)
Insert + F5 // Form fields list
Insert + F6 // Headings list
Insert + F7 // Links list
H // Next heading
T // Next table
B // Next button
// VoiceOver (macOS)
Cmd + F5 // Turn on/off VoiceOver
VO + A // Read all
VO + Right Arrow // Next item
VO + U // Rotor (elements list)
VO + H + H // Next heading
VO + J + J // Next form control
// Optimizing for screen readers
// Accessible button
<button
aria-label="Delete item"
onClick={handleDelete}
>
<TrashIcon aria-hidden="true" />
</button>
// Accessible icon button with text
<button onClick={handleSave}>
<SaveIcon aria-hidden="true" />
<span>Save</span>
</button>
// Hidden from screen readers
<div aria-hidden="true">
Decorative content
</div>
// Accessible navigation
<nav aria-label="Main navigation">
<ul>
<li><a href="/" aria-current="page">Home</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
// Multiple navigation landmarks
<nav aria-label="Primary navigation">...</nav>
<nav aria-label="Footer navigation">...</nav>
// Accessible table
<table>
<caption>Monthly Sales Data</caption>
<thead>
<tr>
<th scope="col">Month</th>
<th scope="col">Sales</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">January</th>
<td>$10,000</td>
</tr>
</tbody>
</table>
// Accessible form with fieldset
<form>
<fieldset>
<legend>Shipping Address</legend>
<label htmlFor="street">Street</label>
<input id="street" type="text" />
<label htmlFor="city">City</label>
<input id="city" type="text" />
</fieldset>
</form>
// Accessible dialog/modal
function Modal({ isOpen, onClose, title, children }) {
const titleId = useId();
const descId = useId();
useEffect(() => {
if (isOpen) {
// Trap focus
const firstFocusable = modalRef.current.querySelector('button, a, input');
firstFocusable?.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={descId}
>
<h2 id={titleId}>{title}</h2>
<div id={descId}>{children}</div>
<button onClick={onClose}>Close</button>
</div>
);
}
// Accessible tabs
function Tabs({ tabs, defaultTab }) {
const [activeTab, setActiveTab] = useState(defaultTab);
return (
<div>
<div role="tablist" aria-label="Content tabs">
{tabs.map((tab) => (
<button
key={tab.id}
role="tab"
aria-selected={activeTab === tab.id}
aria-controls={`panel-${tab.id}`}
id={`tab-${tab.id}`}
onClick={() => setActiveTab(tab.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>
);
}
// Accessible dropdown/combobox
<div>
<label htmlFor="country">Country</label>
<input
id="country"
role="combobox"
aria-expanded={isOpen}
aria-controls="country-list"
aria-autocomplete="list"
value={value}
onChange={handleChange}
/>
{isOpen && (
<ul id="country-list" role="listbox">
{options.map((option) => (
<li
key={option.id}
role="option"
aria-selected={value === option.label}
>
{option.label}
</li>
))}
</ul>
)}
</div>
// Testing checklist for screen readers
// 1. Can you navigate with keyboard only?
// 2. Does Tab key move logically?
// 3. Are headings announced correctly?
// 4. Are landmarks (nav, main, footer) announced?
// 5. Are form labels read before inputs?
// 6. Are error messages announced?
// 7. Are button purposes clear?
// 8. Are icons described?
// 9. Can you complete main tasks?
// 10. Is dynamic content announced?
// React hook for screen reader announcements
function useScreenReaderAnnounce() {
const [message, setMessage] = useState('');
const announce = useCallback((msg, priority = 'polite') => {
setMessage('');
setTimeout(() => {
setMessage(msg);
}, 100);
}, []);
return {
announce,
LiveRegion: () => (
<div
role="status"
aria-live="polite"
aria-atomic="true"
style={{
position: 'absolute',
left: '-10000px',
width: '1px',
height: '1px',
overflow: 'hidden'
}}
>
{message}
</div>
)
};
}
User Testing: Nothing beats real users. Hire screen reader users
for usability testing. Automated tools catch only ~30% of issues.
3. Axe-core Automated Accessibility Testing
| Tool | Type | Coverage | Integration |
|---|---|---|---|
| axe-core BEST | JavaScript library, browser extension | ~57% of WCAG issues (best in class) | Jest, Cypress, Playwright, Chrome DevTools |
| Lighthouse | Chrome DevTools audit | ~40% of issues (powered by axe-core) | Chrome, CI/CD, PageSpeed Insights |
| WAVE | Browser extension, API | ~45% of issues, visual feedback | Manual testing, browser extension |
| Pa11y | CLI tool, CI/CD | ~40% (uses HTML_CodeSniffer) | Command line, automated testing |
Example: Automated Accessibility Testing
// Install axe-core for Jest + React Testing Library
npm install --save-dev jest-axe
// jest.setup.js
import 'jest-axe/extend-expect';
// Component test with axe
// Button.test.tsx
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { Button } from './Button';
expect.extend(toHaveNoViolations);
test('Button has no accessibility violations', async () => {
const { container } = render(<Button>Click me</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
// Test with custom rules
test('custom axe rules', async () => {
const { container } = render(<MyComponent />);
const results = await axe(container, {
rules: {
'color-contrast': { enabled: true },
'label': { enabled: true }
}
});
expect(results).toHaveNoViolations();
});
// Cypress with axe
// cypress/support/commands.ts
import 'cypress-axe';
// cypress/e2e/accessibility.cy.ts
describe('Accessibility tests', () => {
beforeEach(() => {
cy.visit('/');
cy.injectAxe();
});
it('Has no detectable accessibility violations', () => {
cy.checkA11y();
});
it('Checks specific element', () => {
cy.checkA11y('.main-content');
});
it('Excludes specific elements', () => {
cy.checkA11y(null, {
exclude: [['.third-party-widget']]
});
});
it('Checks with specific rules', () => {
cy.checkA11y(null, {
rules: {
'color-contrast': { enabled: true }
}
});
});
});
// Playwright with axe
npm install --save-dev @axe-core/playwright
// tests/accessibility.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('should not have any automatically detectable accessibility issues', async ({ page }) => {
await page.goto('http://localhost:3000');
const accessibilityScanResults = await new AxeBuilder({ page })
.analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
// With specific tags
test('WCAG 2.1 Level AA compliance', async ({ page }) => {
await page.goto('http://localhost:3000');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();
expect(results.violations).toEqual([]);
});
// Exclude third-party content
test('accessibility excluding ads', async ({ page }) => {
await page.goto('http://localhost:3000');
const results = await new AxeBuilder({ page })
.exclude('.advertisement')
.analyze();
expect(results.violations).toEqual([]);
});
// Storybook with addon-a11y
// .storybook/main.ts
export default {
addons: ['@storybook/addon-a11y']
};
// Button.stories.tsx - violations shown in panel
export default {
title: 'Components/Button',
component: Button,
parameters: {
a11y: {
config: {
rules: [
{
id: 'color-contrast',
enabled: true
}
]
}
}
}
};
// Disable a11y for specific story
export const LowContrast: Story = {
parameters: {
a11y: {
disable: true
}
}
};
// Chrome DevTools Lighthouse
// Run from DevTools > Lighthouse tab
// Or programmatically
npm install -g lighthouse
lighthouse https://example.com --only-categories=accessibility --output html --output-path ./report.html
// Pa11y CLI
npm install -g pa11y
// Test single page
pa11y https://example.com
// Test with specific standard
pa11y --standard WCAG2AA https://example.com
// JSON output
pa11y --reporter json https://example.com > results.json
// Pa11y CI for multiple pages
// .pa11yci.json
{
"defaults": {
"standard": "WCAG2AA",
"timeout": 10000
},
"urls": [
"http://localhost:3000/",
"http://localhost:3000/about",
"http://localhost:3000/contact"
]
}
// Run
pa11y-ci
// GitHub Actions for accessibility
// .github/workflows/a11y.yml
name: Accessibility Tests
on: [push, pull_request]
jobs:
a11y:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- run: npm ci
- run: npm run build
- run: npm run serve &
- run: sleep 5
- run: npx pa11y-ci
// React hook for runtime a11y checks (dev only)
import { useEffect } from 'react';
function useA11yCheck() {
useEffect(() => {
if (process.env.NODE_ENV === 'development') {
import('@axe-core/react').then((axe) => {
axe.default(React, ReactDOM, 1000);
});
}
}, []);
}
// Custom axe rules
const customRule = {
id: 'custom-button-name',
impact: 'serious',
selector: 'button',
any: [{
id: 'has-accessible-name',
evaluate: (node) => {
return node.hasAttribute('aria-label') || node.textContent.trim();
}
}]
};
axe.configure({
rules: [customRule]
});
// Accessibility testing strategy
// 1. Automated tests (30% of issues)
// - Run axe-core in unit tests
// - Run Lighthouse in CI/CD
// - Use Storybook addon-a11y
// 2. Manual keyboard testing (40% of issues)
// - Tab through entire page
// - Test with screen reader
// 3. User testing (30% of issues)
// - Test with real users with disabilities
Best Practice: Run axe-core in unit tests, E2E tests, Storybook, and
CI/CD. Catch issues before production. Used by Microsoft, Google, Netflix.
4. Focus Management Roving Tabindex
| Pattern | Use Case | Behavior | Implementation |
|---|---|---|---|
| Roving Tabindex | Toolbars, menus, grids | One tab stop, arrow keys navigate | Only active item has tabindex="0" |
| Focus Trap | Modals, dialogs | Keep focus within container | Cycle focus between first/last |
| Focus Management | SPA navigation, dynamic content | Move focus after actions | Focus heading after navigation |
| Skip Links | Bypass repetitive content | Jump to main content | Hidden link, visible on focus |
Example: Focus Management Patterns
// Roving tabindex for toolbar
function Toolbar({ items }) {
const [focusedIndex, setFocusedIndex] = useState(0);
const itemRefs = useRef([]);
const handleKeyDown = (e, index) => {
let newIndex = index;
switch (e.key) {
case 'ArrowRight':
e.preventDefault();
newIndex = (index + 1) % items.length;
break;
case 'ArrowLeft':
e.preventDefault();
newIndex = (index - 1 + items.length) % items.length;
break;
case 'Home':
e.preventDefault();
newIndex = 0;
break;
case 'End':
e.preventDefault();
newIndex = items.length - 1;
break;
default:
return;
}
setFocusedIndex(newIndex);
itemRefs.current[newIndex]?.focus();
};
return (
<div role="toolbar" aria-label="Text formatting">
{items.map((item, index) => (
<button
key={item.id}
ref={(el) => (itemRefs.current[index] = el)}
tabIndex={index === focusedIndex ? 0 : -1}
onKeyDown={(e) => handleKeyDown(e, index)}
onClick={item.onClick}
aria-label={item.label}
>
{item.icon}
</button>
))}
</div>
);
}
// Focus trap for modal
function useFocusTrap(ref) {
useEffect(() => {
if (!ref.current) return;
const focusableElements = ref.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
const handleTab = (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
};
ref.current.addEventListener('keydown', handleTab);
firstElement?.focus();
return () => {
ref.current?.removeEventListener('keydown', handleTab);
};
}, [ref]);
}
// Modal with focus trap
function Modal({ isOpen, onClose, children }) {
const modalRef = useRef(null);
const previousFocus = useRef(null);
useFocusTrap(modalRef);
useEffect(() => {
if (isOpen) {
previousFocus.current = document.activeElement;
} else if (previousFocus.current) {
previousFocus.current.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div className="modal-backdrop">
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<h2 id="modal-title">Modal Title</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
</div>
);
}
// Focus management for SPA navigation
function useRouteAnnouncement() {
const location = useLocation();
const [message, setMessage] = useState('');
useEffect(() => {
// Focus main heading after navigation
const mainHeading = document.querySelector('h1');
if (mainHeading) {
mainHeading.setAttribute('tabindex', '-1');
mainHeading.focus();
mainHeading.removeAttribute('tabindex');
}
// Announce page change
const pageName = document.title;
setMessage(`Navigated to ${pageName}`);
}, [location]);
return (
<div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{message}
</div>
);
}
// Skip navigation link
<a href="#main-content" className="skip-link">
Skip to main content
</a>
<header>
<nav>{/* Navigation */}</nav>
</header>
<main id="main-content" tabIndex="-1">
{/* Main content */}
</main>
// CSS for skip link
.skip-link {
position: absolute;
top: -40px;
left: 0;
padding: 8px;
background: #000;
color: #fff;
text-decoration: none;
z-index: 100;
}
.skip-link:focus {
top: 0;
}
// Dropdown menu with roving tabindex
function DropdownMenu({ items }) {
const [isOpen, setIsOpen] = useState(false);
const [focusedIndex, setFocusedIndex] = useState(0);
const itemRefs = useRef([]);
const handleKeyDown = (e) => {
if (!isOpen) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setIsOpen(true);
setFocusedIndex(0);
}
return;
}
let newIndex = focusedIndex;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
newIndex = (focusedIndex + 1) % items.length;
break;
case 'ArrowUp':
e.preventDefault();
newIndex = (focusedIndex - 1 + items.length) % items.length;
break;
case 'Home':
e.preventDefault();
newIndex = 0;
break;
case 'End':
e.preventDefault();
newIndex = items.length - 1;
break;
case 'Escape':
e.preventDefault();
setIsOpen(false);
return;
case 'Enter':
case ' ':
e.preventDefault();
items[focusedIndex].onClick();
setIsOpen(false);
return;
default:
return;
}
setFocusedIndex(newIndex);
itemRefs.current[newIndex]?.focus();
};
return (
<div>
<button
aria-haspopup="true"
aria-expanded={isOpen}
onClick={() => setIsOpen(!isOpen)}
onKeyDown={handleKeyDown}
>
Menu
</button>
{isOpen && (
<ul role="menu">
{items.map((item, index) => (
<li
key={item.id}
role="menuitem"
ref={(el) => (itemRefs.current[index] = el)}
tabIndex={index === focusedIndex ? 0 : -1}
onKeyDown={handleKeyDown}
onClick={item.onClick}
>
{item.label}
</li>
))}
</ul>
)}
</div>
);
}
// Focus management after delete action
function deleteItem(id) {
// Delete item
api.deleteItem(id);
// Focus next item or previous if last
const nextItem = document.querySelector(`[data-id="${id}"]`)?.nextElementSibling;
const prevItem = document.querySelector(`[data-id="${id}"]`)?.previousElementSibling;
if (nextItem) {
nextItem.focus();
} else if (prevItem) {
prevItem.focus();
} else {
// No items left, focus add button
document.querySelector('.add-button')?.focus();
}
}
// Grid navigation (2D roving tabindex)
function Grid({ rows, cols }) {
const [focusedCell, setFocusedCell] = useState({ row: 0, col: 0 });
const cellRefs = useRef({});
const handleKeyDown = (e, row, col) => {
let newRow = row;
let newCol = col;
switch (e.key) {
case 'ArrowRight':
newCol = Math.min(col + 1, cols - 1);
break;
case 'ArrowLeft':
newCol = Math.max(col - 1, 0);
break;
case 'ArrowDown':
newRow = Math.min(row + 1, rows - 1);
break;
case 'ArrowUp':
newRow = Math.max(row - 1, 0);
break;
case 'Home':
if (e.ctrlKey) {
newRow = 0;
newCol = 0;
} else {
newCol = 0;
}
break;
case 'End':
if (e.ctrlKey) {
newRow = rows - 1;
newCol = cols - 1;
} else {
newCol = cols - 1;
}
break;
default:
return;
}
e.preventDefault();
setFocusedCell({ row: newRow, col: newCol });
cellRefs.current[`${newRow}-${newCol}`]?.focus();
};
return (
<div role="grid">
{Array.from({ length: rows }).map((_, row) => (
<div key={row} role="row">
{Array.from({ length: cols }).map((_, col) => (
<div
key={col}
role="gridcell"
ref={(el) => (cellRefs.current[`${row}-${col}`] = el)}
tabIndex={focusedCell.row === row && focusedCell.col === col ? 0 : -1}
onKeyDown={(e) => handleKeyDown(e, row, col)}
>
Cell {row},{col}
</div>
))}
</div>
))}
</div>
);
}
// Focus visible styles
button:focus-visible {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
// Remove default outline, add custom for focus-visible
button:focus {
outline: none;
}
button:focus-visible {
outline: 2px solid #0066cc;
outline-offset: 2px;
box-shadow: 0 0 0 4px rgba(0, 102, 204, 0.2);
}
ARIA Authoring Practices: Follow W3C ARIA patterns for standard
widgets. Don't reinvent the wheel - use established patterns for menus, tabs, grids.
5. Color Contrast Checker WebAIM
| Standard | Ratio | Use Case | Example |
|---|---|---|---|
| WCAG AA (Normal) | 4.5:1 minimum | Body text, small text | #767676 on white = 4.54:1 ✅ |
| WCAG AA (Large) | 3:1 minimum | 18pt+ or 14pt+ bold | #959595 on white = 3.02:1 ✅ |
| WCAG AAA (Normal) | 7:1 minimum | Enhanced contrast | #595959 on white = 7.01:1 ✅ |
| UI Components | 3:1 minimum | Buttons, icons, borders | #767676 border = 4.54:1 ✅ |
Example: Color Contrast Implementation
// Color contrast calculation
function getContrastRatio(color1, color2) {
const l1 = getRelativeLuminance(color1);
const l2 = getRelativeLuminance(color2);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
function getRelativeLuminance(color) {
const rgb = hexToRgb(color);
const [r, g, b] = rgb.map((val) => {
val = val / 255;
return val <= 0.03928 ? val / 12.92 : Math.pow((val + 0.055) / 1.055, 2.4);
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
// Check if contrast meets WCAG AA
function meetsWCAGAA(foreground, background, isLarge = false) {
const ratio = getContrastRatio(foreground, background);
const threshold = isLarge ? 3 : 4.5;
return ratio >= threshold;
}
// Example usage
console.log(meetsWCAGAA('#767676', '#FFFFFF')); // true (4.54:1)
console.log(meetsWCAGAA('#999999', '#FFFFFF')); // false (2.85:1)
// Accessible color palette
const colors = {
// Text on white background
textPrimary: '#212529', // 16.1:1 ✅
textSecondary: '#6c757d', // 4.69:1 ✅
textDisabled: '#adb5bd', // 2.73:1 ❌ (decorative only)
// Backgrounds for white text
primaryBg: '#0066cc', // 4.5:1 ✅
successBg: '#28a745', // 3.04:1 ✅ (large text)
dangerBg: '#dc3545', // 4.5:1 ✅
// UI components
border: '#dee2e6', // 1.5:1 (not text, OK for border)
iconActive: '#495057' // 9.1:1 ✅
};
// CSS with good contrast
:root {
--text-primary: #212529;
--text-secondary: #6c757d;
--bg-primary: #ffffff;
--bg-secondary: #f8f9fa;
--border: #dee2e6;
--link: #0066cc;
--link-hover: #004499;
}
body {
color: var(--text-primary);
background: var(--bg-primary);
}
a {
color: var(--link);
}
a:hover {
color: var(--link-hover);
}
// Dark mode with good contrast
[data-theme="dark"] {
--text-primary: #f8f9fa; // 16.1:1 on dark
--text-secondary: #adb5bd; // 6.8:1 on dark
--bg-primary: #212529;
--bg-secondary: #343a40;
--border: #495057;
--link: #66b3ff; // 5.2:1 on dark
}
// Contrast-safe color function
function getAccessibleColor(background) {
const lightText = '#ffffff';
const darkText = '#212529';
const lightRatio = getContrastRatio(lightText, background);
const darkRatio = getContrastRatio(darkText, background);
return lightRatio >= 4.5 ? lightText : darkText;
}
// Auto-adjust text color based on background
function Button({ backgroundColor, children }) {
const textColor = getAccessibleColor(backgroundColor);
return (
<button style={{ backgroundColor, color: textColor }}>
{children}
</button>
);
}
// Common contrast failures
// ❌ Gray text on white background
.text-muted {
color: #999999; // 2.85:1 - FAILS
}
// ✅ Fix: Use darker gray
.text-muted {
color: #6c757d; // 4.69:1 - PASSES
}
// ❌ Light blue link on white
a {
color: #66b3ff; // 3.2:1 - FAILS
}
// ✅ Fix: Use darker blue
a {
color: #0066cc; // 4.5:1 - PASSES
}
// ❌ Yellow warning on white
.warning {
background: #ffc107; // 1.8:1 with white text - FAILS
color: white;
}
// ✅ Fix: Add dark text or darker background
.warning {
background: #ffc107;
color: #212529; // 8.4:1 - PASSES
}
// Testing tools integration
// Jest test for contrast
import { getContrastRatio } from './colorUtils';
test('primary button has sufficient contrast', () => {
const bgColor = '#0066cc';
const textColor = '#ffffff';
const ratio = getContrastRatio(bgColor, textColor);
expect(ratio).toBeGreaterThanOrEqual(4.5);
});
// Storybook addon for contrast checking
// Shows contrast ratio in panel
export default {
title: 'Components/Button',
component: Button,
parameters: {
a11y: {
config: {
rules: [
{
id: 'color-contrast',
enabled: true
}
]
}
}
}
};
// Tailwind with accessible colors
// tailwind.config.js
module.exports = {
theme: {
extend: {
colors: {
// All colors meet WCAG AA with white text
primary: {
DEFAULT: '#0066cc', // 4.5:1
dark: '#004499' // 7.9:1
},
secondary: {
DEFAULT: '#6c757d', // 4.69:1
dark: '#495057' // 9.1:1
}
}
}
}
};
// Design system with contrast tokens
const designTokens = {
color: {
text: {
primary: { value: '#212529', contrast: '16.1:1' },
secondary: { value: '#6c757d', contrast: '4.69:1' }
},
background: {
primary: { value: '#ffffff' },
secondary: { value: '#f8f9fa' }
}
}
};
// Browser DevTools contrast checker
// Chrome DevTools > Inspect element > Styles panel
// Shows contrast ratio with AA/AAA badges
// WebAIM Contrast Checker
// https://webaim.org/resources/contrastchecker/
// Enter foreground and background colors
// See ratio and pass/fail for WCAG AA/AAA
Colorblindness: 8% of men, 0.5% of women have color vision
deficiency. Don't rely on color alone - use icons, text, patterns.
6. ARIA Live Regions Dynamic Content
| Attribute | Behavior | Interruption | Use Case |
|---|---|---|---|
| aria-live="polite" | Announce when idle | No interruption | Status updates, notifications |
| aria-live="assertive" | Announce immediately | Interrupts current speech | Errors, warnings, urgent alerts |
| role="status" | Polite live region | No interruption | Status messages (implicit aria-live="polite") |
| role="alert" | Assertive live region | Interrupts | Important errors (implicit aria-live="assertive") |
Example: ARIA Live Regions
// Basic live region for status updates
<div role="status" aria-live="polite" aria-atomic="true">
{message}
</div>
// Alert for errors
<div role="alert" aria-live="assertive">
{errorMessage}
</div>
// React hook for announcements
function useAnnouncer() {
const [message, setMessage] = useState('');
const [key, setKey] = useState(0);
const announce = useCallback((text, priority = 'polite') => {
setMessage('');
setTimeout(() => {
setMessage(text);
setKey(k => k + 1);
}, 100);
}, []);
return {
announce,
LiveRegion: ({ priority = 'polite' }) => (
<div
key={key}
role={priority === 'assertive' ? 'alert' : 'status'}
aria-live={priority}
aria-atomic="true"
className="sr-only"
>
{message}
</div>
)
};
}
// Usage in component
function SearchResults() {
const { announce, LiveRegion } = useAnnouncer();
const [results, setResults] = useState([]);
useEffect(() => {
if (results.length > 0) {
announce(`${results.length} results found`);
} else {
announce('No results found');
}
}, [results, announce]);
return (
<>
<LiveRegion />
<ul>
{results.map(result => (
<li key={result.id}>{result.name}</li>
))}
</ul>
</>
);
}
// Form validation with live region
function LoginForm() {
const [errors, setErrors] = useState({});
const { announce, LiveRegion } = useAnnouncer();
const handleSubmit = (e) => {
e.preventDefault();
const validationErrors = validate(formData);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
announce(
`Form has ${Object.keys(validationErrors).length} errors`,
'assertive'
);
}
};
return (
<form onSubmit={handleSubmit}>
<LiveRegion priority="assertive" />
{/* Form fields */}
</form>
);
}
// Loading state announcement
function DataTable() {
const [loading, setLoading] = useState(false);
const { announce, LiveRegion } = useAnnouncer();
useEffect(() => {
if (loading) {
announce('Loading data, please wait');
} else {
announce('Data loaded');
}
}, [loading, announce]);
return (
<>
<LiveRegion />
{loading ? <Spinner aria-hidden="true" /> : <Table />}
</>
);
}
// Timer countdown announcement
function Countdown({ seconds }) {
const { announce, LiveRegion } = useAnnouncer();
const [remaining, setRemaining] = useState(seconds);
useEffect(() => {
if (remaining === 60 || remaining === 30 || remaining === 10) {
announce(`${remaining} seconds remaining`);
} else if (remaining === 0) {
announce('Time is up', 'assertive');
}
}, [remaining, announce]);
return (
<>
<LiveRegion />
<div>{remaining} seconds</div>
</>
);
}
// Shopping cart update
function Cart({ items }) {
const { announce, LiveRegion } = useAnnouncer();
const prevItemCount = useRef(items.length);
useEffect(() => {
const currentCount = items.length;
const prevCount = prevItemCount.current;
if (currentCount > prevCount) {
announce(`Item added to cart. ${currentCount} items in cart`);
} else if (currentCount < prevCount) {
announce(`Item removed from cart. ${currentCount} items in cart`);
}
prevItemCount.current = currentCount;
}, [items, announce]);
return (
<>
<LiveRegion />
<ul>
{items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</>
);
}
// aria-atomic: Announce entire region or just changes
<div role="status" aria-live="polite" aria-atomic="true">
{/* Entire content announced */}
Score: {score}
</div>
<div role="status" aria-live="polite" aria-atomic="false">
{/* Only changes announced */}
Score: {score}
</div>
// aria-relevant: What changes to announce
<div
role="log"
aria-live="polite"
aria-relevant="additions" {/* Only new items announced */}
>
{messages.map(msg => (
<div key={msg.id}>{msg.text}</div>
))}
</div>
// Possible values:
// - additions: New nodes
// - removals: Removed nodes
// - text: Text changes
// - all: All changes (default)
// Chat messages with live region
function Chat({ messages }) {
return (
<div
role="log"
aria-live="polite"
aria-relevant="additions"
aria-label="Chat messages"
>
{messages.map(msg => (
<div key={msg.id}>
<strong>{msg.author}:</strong> {msg.text}
</div>
))}
</div>
);
}
// Progress bar with announcements
function ProgressBar({ value, max = 100 }) {
const { announce, LiveRegion } = useAnnouncer();
const prevValue = useRef(value);
useEffect(() => {
const percentage = Math.round((value / max) * 100);
const prevPercentage = Math.round((prevValue.current / max) * 100);
// Announce every 10%
if (percentage % 10 === 0 && percentage !== prevPercentage) {
announce(`${percentage}% complete`);
}
if (percentage === 100) {
announce('Upload complete', 'assertive');
}
prevValue.current = value;
}, [value, max, announce]);
return (
<>
<LiveRegion />
<div
role="progressbar"
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={max}
aria-label="Upload progress"
>
<div style={{ width: `${(value / max) * 100}%` }} />
</div>
</>
);
}
// Combobox with results announcement
function Autocomplete() {
const [results, setResults] = useState([]);
const { announce, LiveRegion } = useAnnouncer();
useEffect(() => {
if (results.length > 0) {
announce(`${results.length} suggestions available`);
}
}, [results, announce]);
return (
<>
<LiveRegion />
<input
role="combobox"
aria-autocomplete="list"
aria-controls="suggestions"
/>
<ul id="suggestions" role="listbox">
{results.map(result => (
<li key={result.id} role="option">
{result.label}
</li>
))}
</ul>
</>
);
}
Live Region Best Practices
- ✅ Use role="status" for status updates
- ✅ Use role="alert" for errors
- ✅ Keep messages concise
- ✅ Use aria-atomic="true" for full message
- ✅ Debounce rapid updates
- ✅ Don't overuse assertive
- ⚠️ Live region must exist in DOM before updates
- ⚠️ Empty and refill to re-announce same message
Accessibility Implementation Summary
- WCAG 2.1 AA: Legal requirement in US/EU. 50 criteria covering images, contrast, keyboard, forms, focus, labels
- Screen Readers: Test with NVDA (Windows), VoiceOver (Mac/iOS). 70%+ users use these. Use semantic HTML and ARIA
- Automated Testing: axe-core catches ~57% of issues. Integrate with Jest, Cypress, Storybook, CI/CD
- Focus Management: Roving tabindex for toolbars/menus, focus trap in modals, restore focus after actions
- Color Contrast: 4.5:1 for normal text, 3:1 for large text and UI. Use WebAIM checker, Chrome DevTools
- Live Regions: aria-live="polite" for status, "assertive" for errors. Announce dynamic content for screen readers
Legal Risk: Over 10,000 ADA lawsuits filed in 2023. Fines up to
$75,000. Make accessibility a priority from day one, not an afterthought.