Accessible Patterns
Complex UI patterns with accessibility built in. For simple elements, prefer semantic HTML without these patterns.
Contents
- Modal dialogs
- Dropdown menus
- Tabs
- Accordions
- Toast notifications
- Data tables
- Skip links
- Forms with validation
Modal dialogs
<button onclick="openModal()" aria-haspopup="dialog">
Open settings
</button>
<div
id="modal"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
hidden
>
<h2 id="modal-title">Settings</h2>
<div><!-- modal content --></div>
<button onclick="closeModal()">Close</button>
</div>
Requirements:
role="dialog"andaria-modal="true"- Label via
aria-labelledbypointing to heading - Focus moves into modal on open (to first focusable element or the dialog itself)
- Focus trapped inside modal while open
- Escape key closes modal
- Focus returns to trigger button on close
- Background content hidden from screen readers (via
aria-modalorinert)
Focus trap pattern (JS):
function trapFocus(modal) {
const focusable = modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
modal.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
});
}
Dropdown menus
<div class="menu-container">
<button
id="menu-button"
aria-expanded="false"
aria-haspopup="menu"
aria-controls="menu"
>
Options
</button>
<ul id="menu" role="menu" aria-labelledby="menu-button" hidden>
<li role="menuitem" tabindex="-1">Edit</li>
<li role="menuitem" tabindex="-1">Duplicate</li>
<li role="menuitem" tabindex="-1">Delete</li>
</ul>
</div>
Requirements:
aria-expandedtoggles on buttonaria-haspopup="menu"on triggerrole="menu"on container,role="menuitem"on items- Arrow keys navigate items (not Tab)
- Enter/Space activates item
- Escape closes and returns focus to button
- Home/End jump to first/last item
Keyboard handling:
menu.addEventListener('keydown', (e) => {
const items = [...menu.querySelectorAll('[role="menuitem"]')];
const current = items.indexOf(document.activeElement);
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
items[(current + 1) % items.length].focus();
break;
case 'ArrowUp':
e.preventDefault();
items[(current - 1 + items.length) % items.length].focus();
break;
case 'Home':
e.preventDefault();
items[0].focus();
break;
case 'End':
e.preventDefault();
items[items.length - 1].focus();
break;
case 'Escape':
closeMenu();
button.focus();
break;
}
});
Tabs
<div class="tabs">
<div role="tablist" aria-label="Account settings">
<button role="tab" aria-selected="true" aria-controls="panel-1" id="tab-1">
Profile
</button>
<button role="tab" aria-selected="false" aria-controls="panel-2" id="tab-2" tabindex="-1">
Security
</button>
<button role="tab" aria-selected="false" aria-controls="panel-3" id="tab-3" tabindex="-1">
Notifications
</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
<!-- Profile content -->
</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
<!-- Security content -->
</div>
<div role="tabpanel" id="panel-3" aria-labelledby="tab-3" hidden>
<!-- Notifications content -->
</div>
</div>
Requirements:
role="tablist"container withrole="tab"buttonsaria-selected="true"on active tabtabindex="-1"on inactive tabs (arrow key navigation, not Tab)role="tabpanel"for content, linked viaaria-controls/aria-labelledby- Left/Right arrows move between tabs
- Home/End jump to first/last tab
Accordions
<div class="accordion">
<h3>
<button
aria-expanded="false"
aria-controls="section1"
id="accordion1"
>
Section 1
</button>
</h3>
<div id="section1" role="region" aria-labelledby="accordion1" hidden>
<p>Content for section 1</p>
</div>
<h3>
<button
aria-expanded="false"
aria-controls="section2"
id="accordion2"
>
Section 2
</button>
</h3>
<div id="section2" role="region" aria-labelledby="accordion2" hidden>
<p>Content for section 2</p>
</div>
</div>
Requirements:
- Headings wrap buttons (preserves heading hierarchy for screen readers)
aria-expandedindicates open/closed statearia-controlslinks button to panel- Panel has
role="region"andaria-labelledby - Space/Enter toggles the panel
Toast notifications
<!-- Container for all toasts -->
<div
role="status"
aria-live="polite"
aria-atomic="true"
class="toast-container"
>
<!-- Toasts injected here -->
</div>
<!-- For urgent/error messages -->
<div
role="alert"
aria-live="assertive"
class="alert-container"
>
<!-- Alerts injected here -->
</div>
Usage:
aria-live="polite": Announced after current speech (success messages, updates)aria-live="assertive": Interrupts immediately (errors, critical alerts)role="status": Implicitaria-live="polite"role="alert": Implicitaria-live="assertive"
Requirements:
- Live region must exist in DOM before content is added
- Don't use for navigation or frequent updates (too noisy)
- Error toasts should be
assertive, success should bepolite - Provide dismiss button if toast persists
- Don't auto-dismiss error messages
Data tables
<table>
<caption>Quarterly sales by region</caption>
<thead>
<tr>
<th scope="col">Region</th>
<th scope="col">Q1</th>
<th scope="col">Q2</th>
<th scope="col">Q3</th>
<th scope="col">Q4</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">North</th>
<td>$1.2M</td>
<td>$1.4M</td>
<td>$1.1M</td>
<td>$1.6M</td>
</tr>
<tr>
<th scope="row">South</th>
<td>$0.9M</td>
<td>$1.0M</td>
<td>$1.2M</td>
<td>$1.3M</td>
</tr>
</tbody>
</table>
Requirements:
<caption>describes the table's purpose<th>for headers withscope="col"orscope="row"- Don't use tables for layout
- For complex tables with multi-level headers, use
id/headersattributes - Sortable columns need
aria-sort="ascending|descending|none"
Skip links
<body>
<a href="#main-content" class="skip-link">Skip to main content</a>
<header><!-- navigation --></header>
<main id="main-content" tabindex="-1">
<!-- page content -->
</main>
</body>
.skip-link {
position: absolute;
left: -9999px;
z-index: 999;
padding: 1em;
background: #000;
color: #fff;
}
.skip-link:focus {
left: 50%;
transform: translateX(-50%);
top: 0;
}
Requirements:
- First focusable element on page
- Links to
<main>or primary content area - Visible on focus (hidden until needed)
- Target element needs
tabindex="-1"for programmatic focus
Forms with validation
<form novalidate>
<div class="field">
<label for="email">Email address <span aria-hidden="true">*</span></label>
<input
type="email"
id="email"
name="email"
required
aria-required="true"
aria-invalid="false"
aria-describedby="email-error"
>
<span id="email-error" class="error" hidden></span>
</div>
<button type="submit">Submit</button>
</form>
On validation error:
function showError(input, message) {
const errorEl = document.getElementById(input.id + '-error');
input.setAttribute('aria-invalid', 'true');
errorEl.textContent = message;
errorEl.hidden = false;
input.focus();
}
Requirements:
<label>for every input (visible, not justaria-label)- Required fields:
required+aria-required="true"+ visual indicator - Errors linked via
aria-describedby aria-invalid="true"on invalid fields- Move focus to first error on submit
- Error messages must be specific ("Email must include @" not "Invalid")
- Don't clear fields on error (let users fix without retyping)