| name | accessibility |
| description | Web accessibility (a11y) — invoked when building UI components, reviewing code for WCAG compliance, fixing keyboard navigation, ARIA usage, color contrast, or screen reader support. |
Web Accessibility (a11y) — WCAG 2.1 AA
WCAG Principles (POUR)
PERCEIVABLE Information must be presentable to users in ways they can perceive.
→ Alt text, captions, sufficient contrast, adaptable layouts
OPERABLE UI components and navigation must be operable.
→ Keyboard accessible, no seizure-inducing content, enough time
UNDERSTANDABLE Information and UI operation must be understandable.
→ Readable text, predictable behavior, input assistance
ROBUST Content must be interpreted reliably by assistive technologies.
→ Valid HTML, correct ARIA, compatible with current/future AT
Semantic HTML (The Foundation)
Semantic HTML gives meaning to structure. Screen readers, search engines, and browser accessibility trees all depend on it.
<div class="header">
<div class="nav">
<div onclick="go('/home')">Home</div>
</div>
</div>
<div class="main">
<div class="article">
<div class="title">My Article</div>
</div>
</div>
<header>
<nav aria-label="Main navigation">
<ul>
<li><a href="/home">Home</a></li>
</ul>
</nav>
</header>
<main>
<article>
<h1>My Article</h1>
<p>Content...</p>
</article>
</main>
<footer>
<p>© 2025 Company</p>
</footer>
Heading Hierarchy
<h1>Page Title</h1>
<h3>Section</h3>
<h1>Another Title</h1>
<h1>Annual Report 2025</h1>
<h2>Financial Overview</h2>
<h3>Revenue</h3>
<h3>Expenses</h3>
<h2>Team Updates</h2>
<h3>Engineering</h3>
Screen reader users navigate by headings — the outline must make sense when read in isolation.
Interactive Elements
<div class="btn" onclick="submitForm()">Submit</div>
<button type="submit">Submit</button>
<a href="/checkout">Go to checkout</a>
<button aria-label="Close dialog">
<svg aria-hidden="true" focusable="false">...</svg>
</button>
Forms
<label for="email">Email address</label>
<input type="email" id="email" name="email" required autocomplete="email">
<label>
Email address
<input type="email" name="email">
</label>
<input type="search" aria-label="Search products" placeholder="Search...">
<h2 id="billing-heading">Billing Address</h2>
<fieldset aria-labelledby="billing-heading">
<legend class="sr-only">Billing Address</legend>
...
</fieldset>
<label for="phone">Phone number</label>
<input
type="tel"
id="phone"
aria-describedby="phone-hint phone-error"
aria-invalid="true"
>
<p id="phone-hint" class="hint">Include country code (e.g. +1)</p>
<p id="phone-error" role="alert" class="error">
Please enter a valid phone number
</p>
Tables
<table>
<caption>Q3 Sales by Region</caption>
<thead>
<tr>
<th scope="col">Region</th>
<th scope="col">Revenue</th>
<th scope="col">Growth</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">North America</th>
<td>$4.2M</td>
<td>+12%</td>
</tr>
</tbody>
<tfoot>
<tr>
<th scope="row">Total</th>
<td>$11.8M</td>
<td>+9%</td>
</tr>
</tfoot>
</table>
ARIA
First rule of ARIA: don't use ARIA if native HTML does it.
Native HTML covers:
<button> role="button", keyboard, activation
<a href> role="link", keyboard
<input type="checkbox"> role="checkbox", checked state
<nav> role="navigation"
<main> role="main"
<h1>–<h6> role="heading", aria-level
Use ARIA for:
Custom widgets (combobox, tree, datepicker, slider)
Dynamic content announcements (live regions)
Relationships between elements (labelledby, describedby)
States not expressible in HTML (aria-expanded, aria-selected)
ARIA Reference Card
<div role="dialog" aria-modal="true" aria-labelledby="dialog-title">
<div role="alert">
<div role="status">
<ul role="listbox">
<li role="option">
<button aria-label="Close">✕</button>
<input aria-labelledby="title-id hint-id">
<input aria-describedby="description-id">
<button aria-expanded="false">Menu</button>
<li role="option" aria-selected="true">Option A</li>
<input type="checkbox" aria-checked="mixed">
<button aria-pressed="true">Bold</button>
<div aria-busy="true">Loading...</div>
<input aria-invalid="true" aria-describedby="err-id">
<button aria-disabled="true">Submit</button>
<svg aria-hidden="true">...</svg>
<span aria-hidden="true">•</span>
Live Regions
<div aria-live="polite" aria-atomic="true" class="sr-only">
</div>
<div role="alert">
</div>
function useLiveAnnouncement() {
const [message, setMessage] = useState('');
const announce = useCallback((text: string) => {
setMessage('');
setTimeout(() => setMessage(text), 50);
}, []);
const LiveRegion = () => (
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{message}
</div>
);
return { announce, LiveRegion };
}
const { announce, LiveRegion } = useLiveAnnouncement();
function handleAddToCart(item: Item) {
addToCart(item);
announce(`${item.name} added to cart. Cart has ${cartCount + 1} items.`);
}
Keyboard Navigation
Focus Management Rules
Tab key: Move forward through interactive elements
Shift+Tab: Move backward
Enter: Activate links, submit forms, activate buttons
Space: Activate buttons, toggle checkboxes
Escape: Close dialogs, menus, tooltips
Arrow keys: Navigate within composite widgets (menu, listbox, tabs, slider)
Home/End: Jump to first/last item in a widget
Focus Indicators
:focus { outline: none; }
*:focus { outline: 0; }
:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 3px;
border-radius: 2px;
}
:focus:not(:focus-visible) {
outline: none;
}
@media (forced-colors: active) {
:focus-visible {
outline: 3px solid ButtonText;
}
}
tabindex
<div role="button" tabindex="0" onclick="activate()" onkeydown="handleKey(event)">
Custom button
</div>
<div id="modal-content" tabindex="-1">
</div>
Modal Focus Management
function Modal({ isOpen, onClose, triggerRef, title, children }: ModalProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<Element | null>(null);
useEffect(() => {
if (isOpen) {
previousFocusRef.current = document.activeElement;
dialogRef.current?.focus();
} else {
(previousFocusRef.current as HTMLElement)?.focus();
}
}, [isOpen]);
function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
if (e.key === 'Escape') {
onClose();
return;
}
if (e.key !== 'Tab') return;
const focusable = dialogRef.current!.querySelectorAll<HTMLElement>(
'[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex="0"]'
);
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();
}
}
if (!isOpen) return null;
return (
<div className="modal-backdrop" onClick={onClose}>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
className="modal"
onClick={e => e.stopPropagation()}
onKeyDown={handleKeyDown}
>
<h2 id="modal-title">{title}</h2>
<button
aria-label="Close dialog"
onClick={onClose}
>
✕
</button>
{children}
</div>
</div>
);
}
Keyboard Pattern: Menu / Dropdown
function MenuButton({ label, items }: MenuButtonProps) {
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const menuRef = useRef<HTMLUListElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
function handleKeyDown(e: KeyboardEvent<HTMLElement>) {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActiveIndex(i => Math.min(i + 1, items.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setActiveIndex(i => Math.max(i - 1, 0));
break;
case 'Home':
e.preventDefault();
setActiveIndex(0);
break;
case 'End':
e.preventDefault();
setActiveIndex(items.length - 1);
break;
case 'Escape':
setOpen(false);
buttonRef.current?.focus();
break;
}
}
return (
<div>
<button
ref={buttonRef}
aria-haspopup="true"
aria-expanded={open}
onClick={() => setOpen(o => !o)}
>
{label}
</button>
{open && (
<ul role="menu" ref={menuRef} onKeyDown={handleKeyDown}>
{items.map((item, i) => (
<li key={item.id} role="none">
<button
role="menuitem"
tabIndex={i === activeIndex ? 0 : -1}
onClick={() => { item.action(); setOpen(false); }}
>
{item.label}
</button>
</li>
))}
</ul>
)}
</div>
);
}
Color and Contrast
WCAG 2.1 AA Requirements:
Normal text (< 18pt or < 14pt bold): 4.5:1 contrast ratio
Large text (≥ 18pt or ≥ 14pt bold): 3:1 contrast ratio
UI components and focus indicators: 3:1 contrast ratio
Decorative elements: No requirement
WCAG 2.1 AAA (enhanced):
Normal text: 7:1
Large text: 4.5:1
Contrast calculation:
Relative luminance formula. Tools do this for you:
- Chrome DevTools: inspect element → Accessibility tab → contrast ratio
- Colour Contrast Analyser (desktop app)
- axe DevTools browser extension
Don't Use Color Alone
<span style="color: red">Required field</span>
<td class="positive">+$1,200</td>
<td class="negative">-$400</td>
<span style="color: red">
<svg aria-hidden="true"></svg>
Required field
</span>
<td class="positive" aria-label="Positive $1,200">
<svg aria-hidden="true"></svg>
+$1,200
</td>
Screen Reader Utility Class
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
.sr-only:focus,
.sr-only:focus-within {
position: static;
width: auto;
height: auto;
padding: inherit;
margin: inherit;
overflow: visible;
clip: auto;
white-space: normal;
}
<a href="#main-content" class="sr-only">Skip to main content</a>
<nav>...</nav>
<main id="main-content">
</main>
Testing
Automated Testing
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('Button has no accessibility violations', async () => {
const { container } = render(<Button onClick={jest.fn()}>Click me</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
test('Modal with open state has no violations', async () => {
const { container } = render(
<Modal isOpen={true} onClose={jest.fn()} title="Confirm">
Are you sure?
</Modal>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
Manual Testing Protocol
Keyboard-only test (no mouse):
- Tab through entire page
- Verify logical tab order matches visual order
- Verify all interactive elements reachable
- Verify focus indicator always visible
- Activate buttons (Enter/Space), links (Enter), dismiss dialogs (Escape)
- Test form submission with keyboard only
Screen reader test:
- Windows: NVDA + Firefox (free)
- macOS: VoiceOver + Safari (built-in, Cmd+F5 to toggle)
- iOS: VoiceOver + Safari (Settings → Accessibility → VoiceOver)
- Android: TalkBack + Chrome
Zoom test:
- Browser zoom to 200%
- No horizontal scrolling
- No content overlap or truncation
- Text still readable (uses
rem not px)
Color perception:
- Chrome DevTools → Rendering → Emulate vision deficiencies
- Test: Deuteranopia, Protanopia, Achromatopsia
- Verify all UI state changes are conveyed without color alone
Anti-Patterns
aria-label on every element — creates noise for screen reader users; label only interactive and landmark elements
- Placeholder as the only label — placeholder disappears on type, isn't announced reliably; always use
<label>
- Custom checkbox with no keyboard support — must handle Space key,
aria-checked, visible focus
display: none to "hide" validation errors — screen readers skip these; use aria-invalid + live region
autofocus on page load — disorienting for screen reader users who haven't heard the page structure yet
- Opened modals that don't trap focus — keyboard users escape the modal and operate background content
- Icon buttons with no label —
<button><img src="trash.png"></button> announces nothing useful
- Non-descriptive link text — "Click here", "Read more" — screen reader link lists become useless
Quick Reference Checklist
Structure:
Interactive elements:
Keyboard:
Color and contrast:
Dynamic content:
Testing: