1# Accessible Patterns
2
3Complex UI patterns with accessibility built in. For simple elements, prefer semantic HTML without these patterns.
4
5## Contents
6- [Modal dialogs](#modal-dialogs)
7- [Dropdown menus](#dropdown-menus)
8- [Tabs](#tabs)
9- [Accordions](#accordions)
10- [Toast notifications](#toast-notifications)
11- [Data tables](#data-tables)
12- [Skip links](#skip-links)
13- [Forms with validation](#forms-with-validation)
14
15## Modal dialogs
16
17```html
18<button onclick="openModal()" aria-haspopup="dialog">
19 Open settings
20</button>
21
22<div
23 id="modal"
24 role="dialog"
25 aria-modal="true"
26 aria-labelledby="modal-title"
27 hidden
28>
29 <h2 id="modal-title">Settings</h2>
30 <div><!-- modal content --></div>
31 <button onclick="closeModal()">Close</button>
32</div>
33```
34
35**Requirements:**
361. `role="dialog"` and `aria-modal="true"`
372. Label via `aria-labelledby` pointing to heading
383. Focus moves into modal on open (to first focusable element or the dialog itself)
394. Focus trapped inside modal while open
405. Escape key closes modal
416. Focus returns to trigger button on close
427. Background content hidden from screen readers (via `aria-modal` or `inert`)
43
44**Focus trap pattern (JS):**
45```js
46function trapFocus(modal) {
47 const focusable = modal.querySelectorAll(
48 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
49 );
50 const first = focusable[0];
51 const last = focusable[focusable.length - 1];
52
53 modal.addEventListener('keydown', (e) => {
54 if (e.key !== 'Tab') return;
55
56 if (e.shiftKey && document.activeElement === first) {
57 e.preventDefault();
58 last.focus();
59 } else if (!e.shiftKey && document.activeElement === last) {
60 e.preventDefault();
61 first.focus();
62 }
63 });
64}
65```
66
67## Dropdown menus
68
69```html
70<div class="menu-container">
71 <button
72 id="menu-button"
73 aria-expanded="false"
74 aria-haspopup="menu"
75 aria-controls="menu"
76 >
77 Options
78 </button>
79
80 <ul id="menu" role="menu" aria-labelledby="menu-button" hidden>
81 <li role="menuitem" tabindex="-1">Edit</li>
82 <li role="menuitem" tabindex="-1">Duplicate</li>
83 <li role="menuitem" tabindex="-1">Delete</li>
84 </ul>
85</div>
86```
87
88**Requirements:**
891. `aria-expanded` toggles on button
902. `aria-haspopup="menu"` on trigger
913. `role="menu"` on container, `role="menuitem"` on items
924. Arrow keys navigate items (not Tab)
935. Enter/Space activates item
946. Escape closes and returns focus to button
957. Home/End jump to first/last item
96
97**Keyboard handling:**
98```js
99menu.addEventListener('keydown', (e) => {
100 const items = [...menu.querySelectorAll('[role="menuitem"]')];
101 const current = items.indexOf(document.activeElement);
102
103 switch (e.key) {
104 case 'ArrowDown':
105 e.preventDefault();
106 items[(current + 1) % items.length].focus();
107 break;
108 case 'ArrowUp':
109 e.preventDefault();
110 items[(current - 1 + items.length) % items.length].focus();
111 break;
112 case 'Home':
113 e.preventDefault();
114 items[0].focus();
115 break;
116 case 'End':
117 e.preventDefault();
118 items[items.length - 1].focus();
119 break;
120 case 'Escape':
121 closeMenu();
122 button.focus();
123 break;
124 }
125});
126```
127
128## Tabs
129
130```html
131<div class="tabs">
132 <div role="tablist" aria-label="Account settings">
133 <button role="tab" aria-selected="true" aria-controls="panel-1" id="tab-1">
134 Profile
135 </button>
136 <button role="tab" aria-selected="false" aria-controls="panel-2" id="tab-2" tabindex="-1">
137 Security
138 </button>
139 <button role="tab" aria-selected="false" aria-controls="panel-3" id="tab-3" tabindex="-1">
140 Notifications
141 </button>
142 </div>
143
144 <div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
145 <!-- Profile content -->
146 </div>
147 <div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
148 <!-- Security content -->
149 </div>
150 <div role="tabpanel" id="panel-3" aria-labelledby="tab-3" hidden>
151 <!-- Notifications content -->
152 </div>
153</div>
154```
155
156**Requirements:**
1571. `role="tablist"` container with `role="tab"` buttons
1582. `aria-selected="true"` on active tab
1593. `tabindex="-1"` on inactive tabs (arrow key navigation, not Tab)
1604. `role="tabpanel"` for content, linked via `aria-controls`/`aria-labelledby`
1615. Left/Right arrows move between tabs
1626. Home/End jump to first/last tab
163
164## Accordions
165
166```html
167<div class="accordion">
168 <h3>
169 <button
170 aria-expanded="false"
171 aria-controls="section1"
172 id="accordion1"
173 >
174 Section 1
175 </button>
176 </h3>
177 <div id="section1" role="region" aria-labelledby="accordion1" hidden>
178 <p>Content for section 1</p>
179 </div>
180
181 <h3>
182 <button
183 aria-expanded="false"
184 aria-controls="section2"
185 id="accordion2"
186 >
187 Section 2
188 </button>
189 </h3>
190 <div id="section2" role="region" aria-labelledby="accordion2" hidden>
191 <p>Content for section 2</p>
192 </div>
193</div>
194```
195
196**Requirements:**
1971. Headings wrap buttons (preserves heading hierarchy for screen readers)
1982. `aria-expanded` indicates open/closed state
1993. `aria-controls` links button to panel
2004. Panel has `role="region"` and `aria-labelledby`
2015. Space/Enter toggles the panel
202
203## Toast notifications
204
205```html
206<!-- Container for all toasts -->
207<div
208 role="status"
209 aria-live="polite"
210 aria-atomic="true"
211 class="toast-container"
212>
213 <!-- Toasts injected here -->
214</div>
215
216<!-- For urgent/error messages -->
217<div
218 role="alert"
219 aria-live="assertive"
220 class="alert-container"
221>
222 <!-- Alerts injected here -->
223</div>
224```
225
226**Usage:**
227- `aria-live="polite"`: Announced after current speech (success messages, updates)
228- `aria-live="assertive"`: Interrupts immediately (errors, critical alerts)
229- `role="status"`: Implicit `aria-live="polite"`
230- `role="alert"`: Implicit `aria-live="assertive"`
231
232**Requirements:**
2331. Live region must exist in DOM before content is added
2342. Don't use for navigation or frequent updates (too noisy)
2353. Error toasts should be `assertive`, success should be `polite`
2364. Provide dismiss button if toast persists
2375. Don't auto-dismiss error messages
238
239## Data tables
240
241```html
242<table>
243 <caption>Quarterly sales by region</caption>
244 <thead>
245 <tr>
246 <th scope="col">Region</th>
247 <th scope="col">Q1</th>
248 <th scope="col">Q2</th>
249 <th scope="col">Q3</th>
250 <th scope="col">Q4</th>
251 </tr>
252 </thead>
253 <tbody>
254 <tr>
255 <th scope="row">North</th>
256 <td>$1.2M</td>
257 <td>$1.4M</td>
258 <td>$1.1M</td>
259 <td>$1.6M</td>
260 </tr>
261 <tr>
262 <th scope="row">South</th>
263 <td>$0.9M</td>
264 <td>$1.0M</td>
265 <td>$1.2M</td>
266 <td>$1.3M</td>
267 </tr>
268 </tbody>
269</table>
270```
271
272**Requirements:**
2731. `<caption>` describes the table's purpose
2742. `<th>` for headers with `scope="col"` or `scope="row"`
2753. Don't use tables for layout
2764. For complex tables with multi-level headers, use `id`/`headers` attributes
2775. Sortable columns need `aria-sort="ascending|descending|none"`
278
279## Skip links
280
281```html
282<body>
283 <a href="#main-content" class="skip-link">Skip to main content</a>
284
285 <header><!-- navigation --></header>
286
287 <main id="main-content" tabindex="-1">
288 <!-- page content -->
289 </main>
290</body>
291```
292
293```css
294.skip-link {
295 position: absolute;
296 left: -9999px;
297 z-index: 999;
298 padding: 1em;
299 background: #000;
300 color: #fff;
301}
302
303.skip-link:focus {
304 left: 50%;
305 transform: translateX(-50%);
306 top: 0;
307}
308```
309
310**Requirements:**
3111. First focusable element on page
3122. Links to `<main>` or primary content area
3133. Visible on focus (hidden until needed)
3144. Target element needs `tabindex="-1"` for programmatic focus
315
316## Forms with validation
317
318```html
319<form novalidate>
320 <div class="field">
321 <label for="email">Email address <span aria-hidden="true">*</span></label>
322 <input
323 type="email"
324 id="email"
325 name="email"
326 required
327 aria-required="true"
328 aria-invalid="false"
329 aria-describedby="email-error"
330 >
331 <span id="email-error" class="error" hidden></span>
332 </div>
333
334 <button type="submit">Submit</button>
335</form>
336```
337
338**On validation error:**
339```js
340function showError(input, message) {
341 const errorEl = document.getElementById(input.id + '-error');
342 input.setAttribute('aria-invalid', 'true');
343 errorEl.textContent = message;
344 errorEl.hidden = false;
345 input.focus();
346}
347```
348
349**Requirements:**
3501. `<label>` for every input (visible, not just `aria-label`)
3512. Required fields: `required` + `aria-required="true"` + visual indicator
3523. Errors linked via `aria-describedby`
3534. `aria-invalid="true"` on invalid fields
3545. Move focus to first error on submit
3556. Error messages must be specific ("Email must include @" not "Invalid")
3567. Don't clear fields on error (let users fix without retyping)