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.
<!-- BAD: meaningless divs -->
<div class="header">
<div class="nav">
<div
</div>
</div>
<div class="main">
<div class="article">
<div class="title">My Article</div>
</div>
</div>
<!-- GOOD: semantic structure -->
<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
<!-- BAD: skipped levels, multiple h1 -->
<h1>Page Title</h1>
<h3>Section</h3> <!-- skipped h2 -->
<h1>Another Title</h1> <!-- second h1 -->
<!-- GOOD: one h1, logical nesting -->
<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
<!-- RULE: <button> for actions, <a> for navigation -->
<!-- BAD: div pretending to be a button -->
<div class="btn"
<!-- Missing: keyboard focus, Enter/Space handling, role, ARIA state -->
<!-- GOOD: native button -->
<button type="submit">Submit</button>
<!-- Gets: focus, keyboard, role="button", activated by Enter+Space -->
<!-- GOOD: link for navigation -->
<a href="/checkout">Go to checkout</a>
<!-- Gets: focus, keyboard, role="link", activated by Enter -->
<!-- Button with only an icon: needs accessible label -->
<button aria-label="Close dialog">
<svg aria-hidden="true" focusable="false">...</svg>
</button>
Forms
<!-- Every input needs a label -->
<!-- Option 1: explicit label (preferred) -->
<label for="email">Email address</label>
<input type="email" id="email" name="email" required autocomplete="email">
<!-- Option 2: wrapping label -->
<label>
Email address
<input type="email" name="email">
</label>
<!-- Option 3: aria-label (when visible label isn't possible) -->
<input type="search" aria-label="Search products" placeholder="Search...">
<!-- Option 4: aria-labelledby (reference existing text) -->
<h2 id="billing-heading">Billing Address</h2>
<fieldset aria-labelledby="billing-heading">
<legend class="sr-only">Billing Address</legend>
...
</fieldset>
<!-- Error messages linked to input -->
<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
<!-- Roles: override or add semantic meaning -->
<div role="dialog" aria-modal="true" aria-labelledby="dialog-title">
<div role="alert"> <!-- assertive live region; for urgent messages -->
<div role="status"> <!-- polite live region; for non-urgent updates -->
<ul role="listbox"> <!-- for custom select/combobox -->
<li role="option"> <!-- item in listbox -->
<!-- Labels -->
<button aria-label="Close">✕</button> <!-- direct text label -->
<input aria-labelledby="title-id hint-id"> <!-- reference elements -->
<input aria-describedby="description-id"> <!-- supplemental info -->
<!-- States -->
<button aria-expanded="false">Menu</button> <!-- toggle state -->
<li role="option" aria-selected="true">Option A</li> <!-- selection -->
<input type="checkbox" aria-checked="mixed"> <!-- tristate checkbox -->
<button aria-pressed="true">Bold</button> <!-- toggle button -->
<div aria-busy="true">Loading...</div> <!-- loading state -->
<input aria-invalid="true" aria-describedby="err-id"> <!-- validation error -->
<button aria-disabled="true">Submit</button> <!-- disabled + focusable -->
<!-- Hiding from AT -->
<svg aria-hidden="true">...</svg> <!-- decorative -->
<span aria-hidden="true">•</span> <!-- visual separator -->
Live Regions
<!-- Polite: announced when user is idle; for status messages -->
<div aria-live="polite" aria-atomic="true" class="sr-only">
<!-- Dynamically inject: "3 items added to cart" -->
</div>
<!-- Assertive: announced immediately; only for urgent errors -->
<div role="alert">
<!-- Inject: "Session expired. Please sign in again." -->
</div>
// React: live region hook
function useLiveAnnouncement() {
const [message, setMessage] = useState('');
const announce = useCallback((text: string) => {
setMessage(''); // Clear first to re-trigger announcement
setTimeout(() => setMessage(text), 50);
}, []);
const LiveRegion = () => (
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{message}
</div>
);
return { announce, LiveRegion };
}
// Usage in cart:
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
/* BAD: removing focus indicator */
:focus { outline: none; }
*:focus { outline: 0; }
/* GOOD: style it, don't remove it */
:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 3px;
border-radius: 2px;
}
/* Hide outline for mouse users (only show for keyboard) */
:focus:not(:focus-visible) {
outline: none;
}
/* High-contrast mode compatible */
@media (forced-colors: active) {
:focus-visible {
outline: 3px solid ButtonText;
}
}
tabindex
<!-- tabindex="0": add to tab order (use sparingly; use native elements instead) -->
<div role="button" tabindex="0"
Custom button
</div>
<!-- tabindex="-1": focusable via JS, not in tab order -->
<div id="modal-content" tabindex="-1">
<!-- Move focus here programmatically when modal opens -->
</div>
<!-- NEVER use tabindex > 0: breaks natural tab order -->
<!-- <div tabindex="5"> — avoid completely -->
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) {
// Save where focus was before modal opened
previousFocusRef.current = document.activeElement;
// Move focus into dialog
dialogRef.current?.focus();
} else {
// Restore focus to trigger element when modal closes
(previousFocusRef.current as HTMLElement)?.focus();
}
}, [isOpen]);
// Focus trap
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"
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
className="modal"
=> e.stopPropagation()}
>
<h2 id="modal-title">{title}</h2>
<button
aria-label="Close dialog"
>
✕
</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}
=> setOpen(o => !o)}
>
{label}
</button>
{open && (
<ul role="menu" ref={menuRef}
{items.map((item, i) => (
<li key={item.id} role="none">
<button
role="menuitem"
tabIndex={i === activeIndex ? 0 : -1}
=> { 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
<!-- BAD: only color conveys meaning -->
<span style="color: red">Required field</span>
<td class="positive">+$1,200</td>
<td class="negative">-$400</td>
<!-- GOOD: color + text/icon/pattern -->
<span style="color: red">
<svg aria-hidden="true"><!-- asterisk icon --></svg>
Required field
</span>
<td class="positive" aria-label="Positive $1,200">
<svg aria-hidden="true"><!-- up arrow --></svg>
+$1,200
</td>
Screen Reader Utility Class
/* sr-only: visually hidden but available to screen readers */
.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;
}
/* Make focusable sr-only elements visible on focus (for skip links) */
.sr-only:focus,
.sr-only:focus-within {
position: static;
width: auto;
height: auto;
padding: inherit;
margin: inherit;
overflow: visible;
clip: auto;
white-space: normal;
}
<!-- Skip link: first interactive element on page -->
<a href="#main-content" class="sr-only">Skip to main content</a>
<nav>...</nav>
<main id="main-content">
<!-- keyboard users jump here, skipping nav -->
</main>
Testing
Automated Testing
// jest-axe: catch violations in component tests
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 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} 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
remnotpx)
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-labelon 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: noneto "hide" validation errors — screen readers skip these; usearia-invalid+ live regionautofocuson 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:
- One
<h1>per page; heading levels not skipped -
<main>,<nav>,<header>,<footer>,<aside>landmarks used correctly - All lists use
<ul>/<ol>/<dl>; no list-like divs
Interactive elements:
-
<button>for actions,<a href>for navigation; no<div onClick> - All icon-only buttons have
aria-label - Every form input has a visible
<label>oraria-label - Error messages linked via
aria-describedby; field markedaria-invalid="true"
Keyboard:
- All interactive elements reachable by Tab
- Focus indicator visible (
outlinenot removed) - Modal traps focus; Escape closes; focus returns to trigger on close
- Custom widgets follow ARIA keyboard patterns (arrows, Home/End, Escape)
Color and contrast:
- Normal text ≥ 4.5:1 contrast ratio
- UI component borders ≥ 3:1 contrast ratio
- No information conveyed by color alone
Dynamic content:
- Loading states announced via
aria-liveoraria-busy - Success/error messages announced via live region
- Page title updated on SPA route changes
Testing:
- Zero axe-core violations in automated tests
- Keyboard-only navigation verified
- Screen reader tested (VoiceOver/NVDA)
- 200% zoom tested