Accessibility Patterns
Reference guide for building inclusive, accessible web interfaces that comply with WCAG 2.1 AA standards.
Core Principles (POUR)
| Principle | Meaning | Key Question |
|---|
| Perceivable | Content is available to all senses | Can users see, hear, or read it? |
| Operable | Interface works with all input methods | Can users navigate with keyboard only? |
| Understandable | Content and UI are predictable | Can users understand and recover from errors? |
| Robust | Works across assistive technologies | Does it work with screen readers and future tools? |
Semantic HTML Reference
Use the right element for the job. Semantic HTML provides accessibility for free.
Document Structure
<header> <!-- Site/section header, landmarks for screen readers -->
<nav> <!-- Navigation links, announced as "navigation" -->
<main> <!-- Primary content, skip-to target -->
<article> <!-- Self-contained content (blog post, card) -->
<section> <!-- Thematic grouping with heading -->
<aside> <!-- Tangentially related (sidebar, callout) -->
<footer> <!-- Site/section footer -->
Interactive Elements
| Need | Use | NOT |
|---|
| Clickable action | <button> | <div onclick> or <span onclick> |
| Navigation link | <a href="..."> | <div onclick="navigate()"> |
| Text input | <input type="text"> | <div contenteditable> |
| Selection | <select> + <option> | Custom dropdown without ARIA |
| Toggle | <input type="checkbox"> | <div class="toggle"> |
| Form group | <fieldset> + <legend> | <div class="form-group"> |
Heading Hierarchy
<!-- CORRECT: Logical hierarchy, no skipped levels -->
<h1>Page Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>
<h3>Subsection</h3>
<h2>Another Section</h2>
<!-- WRONG: Skipped levels, multiple h1, heading for styling -->
<h1>Title</h1>
<h1>Another Title</h1> <!-- Only one h1 per page -->
<h4>Jumped from h1 to h4</h4> <!-- Skipped h2, h3 -->
ARIA Roles, States, and Properties
ARIA supplements HTML semantics. The first rule of ARIA: do not use ARIA if native HTML provides the semantics.
Landmark Roles
Most of these are already implied by semantic HTML.
| Role | HTML Equivalent | When to Use ARIA |
|---|
banner | <header> (top-level) | Nested headers needing landmark |
navigation | <nav> | Rarely needed |
main | <main> | Rarely needed |
complementary | <aside> | Rarely needed |
contentinfo | <footer> (top-level) | Nested footers needing landmark |
search | <search> | Browsers without <search> support |
form | <form> (with name) | Forms without accessible name |
region | <section> (with name) | Generic labeled regions |
Common ARIA Attributes
| Attribute | Purpose | Example |
|---|
aria-label | Invisible label for element | <button aria-label="Close dialog">X</button> |
aria-labelledby | Points to visible label element | <div aria-labelledby="heading-id"> |
aria-describedby | Points to descriptive text | <input aria-describedby="password-help"> |
aria-expanded | Toggle/disclosure state | <button aria-expanded="false">Menu</button> |
aria-hidden | Hide from assistive tech | <span aria-hidden="true">decorative icon</span> |
aria-live | Announce dynamic content | <div aria-live="polite">Status: Saved</div> |
aria-required | Field is required | <input aria-required="true"> (prefer required attr) |
aria-invalid | Field has validation error | <input aria-invalid="true"> |
aria-current | Current item in a set | <a aria-current="page">Home</a> |
aria-disabled | Disabled but focusable | <button aria-disabled="true">Submit</button> |
Live Regions
For content that updates dynamically (notifications, status messages, chat).
<!-- Polite: announced after current speech finishes -->
<div aria-live="polite" aria-atomic="true">
3 items in your cart
</div>
<!-- Assertive: interrupts current speech (use sparingly) -->
<div aria-live="assertive" role="alert">
Error: Payment failed. Please try again.
</div>
<!-- Status: polite + role=status (form feedback, progress) -->
<div role="status">
Saving... Done!
</div>
| Politeness | When to Use |
|---|
polite | Status updates, cart counts, non-urgent info |
assertive | Errors, warnings, time-sensitive alerts |
off | Disable announcements (default) |
Keyboard Navigation
Focus Management Rules
| Rule | Implementation |
|---|
| All interactive elements are focusable | Use native HTML elements or tabindex="0" |
| Focus order matches visual order | Source order = visual order, avoid CSS reordering |
| Focus is visible | Never outline: none without a visible alternative |
| No keyboard traps | User can always Tab away (except modal dialogs) |
| Skip links available | First focusable element skips to main content |
Skip Link Pattern
<!-- First element in <body>, visually hidden until focused -->
<a href="#main-content" class="skip-link">
Skip to main content
</a>
<!-- ... navigation ... -->
<main id="main-content" tabindex="-1">
<!-- Content starts here -->
</main>
.skip-link {
position: absolute;
top: -40px;
left: 0;
padding: 8px 16px;
background: #000;
color: #fff;
z-index: 100;
transition: top 0.2s;
}
.skip-link:focus {
top: 0;
}
Key Bindings Reference
| Pattern | Expected Keys |
|---|
| Buttons | Enter or Space to activate |
| Links | Enter to follow |
| Checkboxes | Space to toggle |
| Radio buttons | Arrow keys to move, Space to select |
| Tabs | Arrow keys to switch, Tab to exit tab list |
| Menus | Arrow keys to navigate, Enter to select, Escape to close |
| Dialogs | Escape to close, Tab trapped inside, focus on close or first element |
| Dropdowns | Arrow keys to navigate, Enter to select, Escape to close |
Tab Trap for Modals
function trapFocus(dialog) {
const focusable = dialog.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]), ' +
'select:not([disabled]), textarea:not([disabled]), [tabindex="0"]'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
dialog.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === first) {
last.focus();
e.preventDefault();
}
} else {
if (document.activeElement === last) {
first.focus();
e.preventDefault();
}
}
});
first.focus();
}
Color Contrast Requirements
WCAG 2.1 AA Minimums
| Content Type | Minimum Ratio | Example |
|---|
| Normal text (<18px / <14px bold) | 4.5:1 | #595959 on #FFFFFF = 7:1 |
| Large text (>=18px / >=14px bold) | 3:1 | #767676 on #FFFFFF = 4.5:1 |
| UI components & graphical objects | 3:1 | Borders, icons, focus indicators |
| Decorative / logos | No requirement | Brand logos are exempt |
Testing Contrast
# Browser DevTools: Inspect element > Color picker shows ratio
# Chrome: Lighthouse > Accessibility audit
# Firefox: Accessibility Inspector > Check for issues
Do Not Rely on Color Alone
<!-- BAD: Color is the only indicator -->
<span style="color: red;">Error in this field</span>
<!-- GOOD: Color + icon + text -->
<span class="error">
<svg aria-hidden="true"><!-- error icon --></svg>
Error: Email address is required
</span>
<!-- BAD: Link distinguished only by color -->
<p>Read our <span style="color: blue;">terms of service</span></p>
<!-- GOOD: Link has underline (and color) -->
<p>Read our <a href="/terms">terms of service</a></p>
Form Accessibility
Labels and Instructions