byaddyosmani· 31 skills
Audit and improve web accessibility following WCAG 2.2 guidelines. Use when asked to "improve accessibility", "a11y audit", "WCAG compliance", "screen reader support", "keyboard navigation", or "make accessible".
$npx -y skills add addyosmani/web-quality-skills --skill accessibilityInstalls into the current project.
Run `npx skills use "https://github.com/addyosmani/web-quality-skills" --skill "addyosmani/web-quality-skills/accessibility"` and follow the generated skill instructions now. Read its complete output, redirecting it to a temporary file first if necessary. Resolve relative paths from the supporting-files directory it provides.
Use the skills in "https://github.com/addyosmani/web-quality-skills" that are relevant to the current task. Run `npx skills add "https://github.com/addyosmani/web-quality-skills"` and select the relevant skills, then follow their instructions.
| 1 | # Accessibility (a11y) |
| 2 | |
| 3 | Comprehensive accessibility guidelines based on WCAG 2.2 and Lighthouse accessibility audits. Goal: make content usable by everyone, including people with disabilities. |
| 4 | |
| 5 | ## WCAG Principles: POUR |
| 6 | |
| 7 | | Principle | Description | |
| 8 | |-----------|-------------| |
| 9 | | **P**erceivable | Content can be perceived through different senses | |
| 10 | | **O**perable | Interface can be operated by all users | |
| 11 | | **U**nderstandable | Content and interface are understandable | |
| 12 | | **R**obust | Content works with assistive technologies | |
| 13 | |
| 14 | ## Conformance levels |
| 15 | |
| 16 | | Level | Requirement | Target | |
| 17 | |-------|-------------|--------| |
| 18 | | **A** | Minimum accessibility | Must pass | |
| 19 | | **AA** | Standard compliance | Should pass (legal requirement in many jurisdictions) | |
| 20 | | **AAA** | Enhanced accessibility | Nice to have | |
| 21 | |
| 22 | --- |
| 23 | |
| 24 | ## Perceivable |
| 25 | |
| 26 | ### Text alternatives (1.1) |
| 27 | |
| 28 | **Images require alt text:** |
| 29 | ```html |
| 30 | <!-- ❌ Missing alt --> |
| 31 | <img src="chart.png"> |
| 32 | |
| 33 | <!-- ✅ Descriptive alt --> |
| 34 | <img src="chart.png" alt="Bar chart showing 40% increase in Q3 sales"> |
| 35 | |
| 36 | <!-- ✅ Decorative image (empty alt) --> |
| 37 | <img src="decorative-border.png" alt="" role="presentation"> |
| 38 | |
| 39 | <!-- ✅ Complex image with longer description --> |
| 40 | <figure> |
| 41 | <img src="infographic.png" alt="2024 market trends infographic" |
| 42 | aria-describedby="infographic-desc"> |
| 43 | <figcaption id="infographic-desc"> |
| 44 | <!-- Detailed description --> |
| 45 | </figcaption> |
| 46 | </figure> |
| 47 | ``` |
| 48 | |
| 49 | **Icon buttons need accessible names:** |
| 50 | ```html |
| 51 | <!-- ❌ No accessible name --> |
| 52 | <button><svg><!-- menu icon --></svg></button> |
| 53 | |
| 54 | <!-- ✅ Using aria-label --> |
| 55 | <button aria-label="Open menu"> |
| 56 | <svg aria-hidden="true"><!-- menu icon --></svg> |
| 57 | </button> |
| 58 | |
| 59 | <!-- ✅ Using visually hidden text --> |
| 60 | <button> |
| 61 | <svg aria-hidden="true"><!-- menu icon --></svg> |
| 62 | <span class="visually-hidden">Open menu</span> |
| 63 | </button> |
| 64 | ``` |
| 65 | |
| 66 | **Visually hidden class:** |
| 67 | ```css |
| 68 | .visually-hidden { |
| 69 | position: absolute; |
| 70 | width: 1px; |
| 71 | height: 1px; |
| 72 | padding: 0; |
| 73 | margin: -1px; |
| 74 | overflow: hidden; |
| 75 | clip: rect(0, 0, 0, 0); |
| 76 | white-space: nowrap; |
| 77 | border: 0; |
| 78 | } |
| 79 | ``` |
| 80 | |
| 81 | ### Color contrast (1.4.3, 1.4.6) |
| 82 | |
| 83 | | Text Size | AA minimum | AAA enhanced | |
| 84 | |-----------|------------|--------------| |
| 85 | | Normal text (< 18px / < 14px bold) | 4.5:1 | 7:1 | |
| 86 | | Large text (≥ 18px / ≥ 14px bold) | 3:1 | 4.5:1 | |
| 87 | | UI components & graphics | 3:1 | 3:1 | |
| 88 | |
| 89 | ```css |
| 90 | /* ❌ Low contrast (2.5:1) */ |
| 91 | .low-contrast { |
| 92 | color: #999; |
| 93 | background: #fff; |
| 94 | } |
| 95 | |
| 96 | /* ✅ Sufficient contrast (7:1) */ |
| 97 | .high-contrast { |
| 98 | color: #333; |
| 99 | background: #fff; |
| 100 | } |
| 101 | |
| 102 | /* ✅ Focus states need contrast too (3:1 against background, WCAG 1.4.11) */ |
| 103 | :focus-visible { |
| 104 | outline: 2px solid currentColor; |
| 105 | outline-offset: 2px; |
| 106 | } |
| 107 | ``` |
| 108 | |
| 109 | **Don't rely on color alone:** |
| 110 | ```html |
| 111 | <!-- ❌ Only color indicates error --> |
| 112 | <input class="error-border"> |
| 113 | <style>.error-border { border-color: red; }</style> |
| 114 | |
| 115 | <!-- ✅ Color + icon + text --> |
| 116 | <div class="field-error"> |
| 117 | <input aria-invalid="true" aria-describedby="email-error"> |
| 118 | <span id="email-error" class="error-message"> |
| 119 | <svg aria-hidden="true"><!-- error icon --></svg> |
| 120 | Please enter a valid email address |
| 121 | </span> |
| 122 | </div> |
| 123 | ``` |
| 124 | |
| 125 | ### Media alternatives (1.2) |
| 126 | |
| 127 | ```html |
| 128 | <!-- Video with captions --> |
| 129 | <video controls> |
| 130 | <source src="video.mp4" type="video/mp4"> |
| 131 | <track kind="captions" src="captions.vtt" srclang="en" label="English" default> |
| 132 | <track kind="descriptions" src="descriptions.vtt" srclang="en" label="Descriptions"> |
| 133 | </video> |
| 134 | |
| 135 | <!-- Audio with transcript --> |
| 136 | <audio controls> |
| 137 | <source src="podcast.mp3" type="audio/mp3"> |
| 138 | </audio> |
| 139 | <details> |
| 140 | <summary>Transcript</summary> |
| 141 | <p>Full transcript text...</p> |
| 142 | </details> |
| 143 | ``` |
| 144 | |
| 145 | --- |
| 146 | |
| 147 | ## Operable |
| 148 | |
| 149 | ### Keyboard accessible (2.1) |
| 150 | |
| 151 | **All functionality must be keyboard accessible.** Prefer native interactive elements — `<button>`, `<a href>`, and form controls handle Enter/Space activation, focus, and assistive-tech semantics for free. Only add manual keyboard handling when you cannot use a native element. |
| 152 | |
| 153 | ```html |
| 154 | <!-- ❌ Non-interactive element with click only: not focusable, no keyboard activation --> |
| 155 | <div class="card" onclick="handleAction()">Open</div> |
| 156 | |
| 157 | <!-- ✅ Best: use a native button --> |
| 158 | <button type="button" onclick="handleAction()">Open</button> |
| 159 | ``` |
| 160 | |
| 161 | ```javascript |
| 162 | // ✅ When you MUST use a non-interactive element (e.g. div with role="button"), |
| 163 | // make it focusable AND handle keyboard activation. Do NOT add this to a native |
| 164 | // <button> — Enter/Space already fire click, so you'd double-trigger. |
| 165 | element.setAttribute('role', 'button'); |
| 166 | element.setAttribute('tabindex', '0'); |
| 167 | element.addEventListener('click', handleAction); |
| 168 | element.addEventListener('keydown', (e) => { |
| 169 | if (e.key === 'Enter' || e.key === ' ') { |
| 170 | e.preventDefault(); |
| 171 | handleAction(); |
| 172 | } |
| 173 | }); |
| 174 | ``` |
| 175 | |
| 176 | **No keyboard traps.** Users must be able to Tab into and out of every component. Use the [modal focus trap pattern](references/A11Y-PATTERNS.md#modal-focus-trap) for dialogs—the native `<dialog>` element handles this automatically. |
| 177 | |
| 178 | ### Focus visible (2.4.7) |
| 179 | |
| 180 | ```css |
| 181 | /* ❌ Never remove focus outlines */ |
| 182 | *:focus { outline: none; } |
| 183 | |
| 184 | /* ✅ Use :focus-visible for keyboard-only focus */ |
| 185 | :focus { |
| 186 | outline: none; |
| 187 | } |
| 188 | |
| 189 | :focus-visible { |
| 190 | outline: 2px solid currentColor; /* inherits text color → already contrast-checked */ |
| 191 | outline-offset: 2px; |
| 192 | } |
| 193 | |
| 194 | /* ✅ Or pick a brand color and verify ≥3:1 contrast against every background it lands on */ |
| 195 | button:focus-visible { |
| 196 | box-shadow: 0 0 0 3px rgba(0, 95, 204, 0.5); |
| 197 | } |
| 198 | ``` |
| 199 | |
| 200 | ### Focus not obscured (2.4.11) — new in 2.2 |
| 201 | |
| 202 | When an element receives keyboard focus, it must not be entirely hidden by other author-created content such as sticky headers, footers, or overlapping panels. At Level AAA (2.4.12), no part of the focused element may be hidden. |
| 203 | |
| 204 | ```css |
| 205 | /* ✅ Account for sticky headers when scrolling to focused elements */ |
| 206 | :target { |
| 207 | scroll-margin-top: 80px; |
| 208 | } |
| 209 | |
| 210 | /* ✅ Ensure focused items clear fixed/sticky bars */ |
| 211 | :focus { |
| 212 | scroll-margin-top: 80px; |
| 213 | scroll-margin-bottom: 60px; |
| 214 | } |
| 215 | ``` |
| 216 | |
| 217 | ### Skip links (2.4.1) |
| 218 | |
| 219 | Provide a skip link so keyboard users can bypass repetitive navigation. See the [skip link pattern](references/A11Y-PATTERNS.md#skip-link) for full markup and styles. |
| 220 | |
| 221 | ### Target size (2.5.8) — new in 2.2 |
| 222 | |
| 223 | Interactive targets must be at least **24 × 24 CSS pixels** (AA). Exceptions: inline text links, elements where the browser controls the size, and targets where a 24px circle centered on the bounding box does not overlap another target. |
| 224 | |
| 225 | ```css |
| 226 | /* ✅ Minimum target size */ |
| 227 | button, |
| 228 | [role="button"], |
| 229 | input[type="checkbox"] + label, |
| 230 | input[type="radio"] + label { |
| 231 | min-width: 24px; |
| 232 | min-height: 24px; |
| 233 | } |
| 234 | |
| 235 | /* ✅ Comfortable target size (recommended 44×44) */ |
| 236 | .touch-target { |
| 237 | min-width: 44px; |
| 238 | min-height: 44px; |
| 239 | display: inline-flex; |
| 240 | align-items: center; |
| 241 | justify-content: center; |
| 242 | } |
| 243 | ``` |
| 244 | |
| 245 | ### Dragging movements (2.5.7) — new in 2.2 |
| 246 | |
| 247 | Any action that requires dragging must have a single-pointer alternative (e.g., buttons, inputs). See the [dragging movements pattern](references/A11Y-PATTERNS.md#dragging-movements) for a sortable-list example. |
| 248 | |
| 249 | ### Timing (2.2) |
| 250 | |
| 251 | ```javascript |
| 252 | // Allow users to extend time limits |
| 253 | function showSessionWarning() { |
| 254 | const modal = createModal({ |
| 255 | title: 'Session Expiring', |
| 256 | content: 'Your session will expire in 2 minutes.', |
| 257 | actions: [ |
| 258 | { label: 'Extend session', action: extendSession }, |
| 259 | { label: 'Log out', action: logout } |
| 260 | ], |
| 261 | timeout: 120000 |
| 262 | }); |
| 263 | } |
| 264 | ``` |
| 265 | |
| 266 | ### Motion (2.3) |
| 267 | |
| 268 | ```css |
| 269 | /* Respect reduced motion preference */ |
| 270 | @media (prefers-reduced-motion: reduce) { |
| 271 | *, |
| 272 | *::before, |
| 273 | *::after { |
| 274 | animation-duration: 0.01ms !important; |
| 275 | animation-iteration-count: 1 !important; |
| 276 | transition-duration: 0.01ms !important; |
| 277 | scroll-behavior: auto !important; |
| 278 | } |
| 279 | } |
| 280 | ``` |
| 281 | |
| 282 | --- |
| 283 | |
| 284 | ## Understandable |
| 285 | |
| 286 | ### Page language (3.1.1) |
| 287 | |
| 288 | ```html |
| 289 | <!-- ❌ No language specified --> |
| 290 | <html> |
| 291 | |
| 292 | <!-- ✅ Language specified --> |
| 293 | <html lang="en"> |
| 294 | |
| 295 | <!-- ✅ Language changes within page --> |
| 296 | <p>The French word for hello is <span lang="fr">bonjour</span>.</p> |
| 297 | ``` |
| 298 | |
| 299 | ### Consistent navigation (3.2.3) |
| 300 | |
| 301 | ```html |
| 302 | <!-- Navigation should be consistent across pages --> |
| 303 | <nav aria-label="Main"> |
| 304 | <ul> |
| 305 | <li><a href="/" aria-current="page">Home</a></li> |
| 306 | <li><a href="/products">Products</a></li> |
| 307 | <li><a href="/about">About</a></li> |
| 308 | </ul> |
| 309 | </nav> |
| 310 | ``` |
| 311 | |
| 312 | ### Consistent help (3.2.6) — new in 2.2 |
| 313 | |
| 314 | If a help mechanism (contact info, chat widget, FAQ link, self-help option) is repeated across multiple pages, it must appear in the **same relative order** each time. Users who rely on consistent placement shouldn't have to hunt for help on every page. |
| 315 | |
| 316 | ### Form labels (3.3.2) |
| 317 | |
| 318 | Every input needs a programmatically associated label. See the [form labels pattern](references/A11Y-PATTERNS.md#form-labels) for explicit, implicit, and instructional examples. |
| 319 | |
| 320 | ### Error handling (3.3.1, 3.3.3) |
| 321 | |
| 322 | Announce errors to screen readers with `role="alert"` or `aria-live`, set `aria-invalid="true"` on invalid fields, and focus the first error on submit. See the [error handling pattern](references/A11Y-PATTERNS.md#error-handling) for full markup and JS. |
| 323 | |
| 324 | ### Redundant entry (3.3.7) — new in 2.2 |
| 325 | |
| 326 | Don't force users to re-enter information they already provided in the same session. Auto-populate from earlier steps, or let users select from previously entered values. Exceptions: security re-confirmation and content that has expired. |
| 327 | |
| 328 | ```html |
| 329 | <!-- ✅ Auto-fill shipping address from billing --> |
| 330 | <fieldset> |
| 331 | <legend>Shipping address</legend> |
| 332 | <label> |
| 333 | <input type="checkbox" id="same-as-billing" checked> |
| 334 | Same as billing address |
| 335 | </label> |
| 336 | <!-- Fields auto-populated when checked --> |
| 337 | </fieldset> |
| 338 | ``` |
| 339 | |
| 340 | ### Accessible authentication (3.3.8) — new in 2.2 |
| 341 | |
| 342 | Login flows must not rely on cognitive function tests (e.g., remembering a password, solving a puzzle) unless at least one of: |
| 343 | - A copy-paste or autofill mechanism is available |
| 344 | - An alternative method exists (e.g., passkey, SSO, email link) |
| 345 | - The test uses object recognition or personal content (AA only; AAA removes this exception) |
| 346 | |
| 347 | ```html |
| 348 | <!-- ✅ Allow paste in password fields --> |
| 349 | <input type="password" id="password" autocomplete="current-password"> |
| 350 | |
| 351 | <!-- ✅ Offer passwordless alternatives --> |
| 352 | <button type="button">Sign in with passkey</button> |
| 353 | <button type="button">Email me a login link</button> |
| 354 | ``` |
| 355 | |
| 356 | --- |
| 357 | |
| 358 | ## Robust |
| 359 | |
| 360 | ### ARIA usage (4.1.2) |
| 361 | |
| 362 | **Prefer native elements:** |
| 363 | ```html |
| 364 | <!-- ❌ ARIA role on div --> |
| 365 | <div role="button" tabindex="0">Click me</div> |
| 366 | |
| 367 | <!-- ✅ Native button --> |
| 368 | <button>Click me</button> |
| 369 | |
| 370 | <!-- ❌ ARIA checkbox --> |
| 371 | <div role="checkbox" aria-checked="false">Option</div> |
| 372 | |
| 373 | <!-- ✅ Native checkbox --> |
| 374 | <label><input type="checkbox"> Option</label> |
| 375 | ``` |
| 376 | |
| 377 | **When ARIA is needed,** use the correct roles and states. See the [ARIA tabs pattern](references/A11Y-PATTERNS.md#aria-tabs) for a complete tablist example. |
| 378 | |
| 379 | ### Live regions (4.1.3) |
| 380 | |
| 381 | Use `aria-live` regions to announce dynamic content changes without moving focus. See the [live regions pattern](references/A11Y-PATTERNS.md#live-regions-and-notifications) for markup and a `showNotification()` helper. |
| 382 | |
| 383 | --- |
| 384 | |
| 385 | ## Testing checklist |
| 386 | |
| 387 | ### Automated testing |
| 388 | ```bash |
| 389 | # Lighthouse accessibility audit |
| 390 | npx lighthouse https://example.com --only-categories=accessibility |
| 391 | |
| 392 | # axe-core |
| 393 | npm install @axe-core/cli -g |
| 394 | axe https://example.com |
| 395 | ``` |
| 396 | |
| 397 | ### Manual testing |
| 398 | |
| 399 | - [ ] **Keyboard navigation:** Tab through entire page, use Enter/Space to activate |
| 400 | - [ ] **Screen reader:** Test with VoiceOver (Mac), NVDA (Windows), or TalkBack (Android) |
| 401 | - [ ] **Zoom:** Content usable at 200% zoom |
| 402 | - [ ] **High contrast:** Test with Windows High Contrast Mode |
| 403 | - [ ] **Reduced motion:** Test with `prefers-reduced-motion: reduce` |
| 404 | - [ ] **Focus order:** Logical and follows visual order |
| 405 | - [ ] **Target size:** Interactive elements meet 24×24px minimum |
| 406 | |
| 407 | See the [screen reader commands reference](references/A11Y-PATTERNS.md#screen-reader-commands) for VoiceOver and NVDA shortcuts. |
| 408 | |
| 409 | --- |
| 410 | |
| 411 | ## Common issues by impact |
| 412 | |
| 413 | ### Critical (fix immediately) |
| 414 | 1. Missing form labels |
| 415 | 2. Missing image alt text |
| 416 | 3. Insufficient color contrast |
| 417 | 4. Keyboard traps |
| 418 | 5. No focus indicators |
| 419 | |
| 420 | ### Serious (fix before launch) |
| 421 | 1. Missing page language |
| 422 | 2. Missing heading structure |
| 423 | 3. Non-descriptive link text |
| 424 | 4. Auto-playing media |
| 425 | 5. Missing skip links |
| 426 | |
| 427 | ### Moderate (fix soon) |
| 428 | 1. Missing ARIA labels on icons |
| 429 | 2. Inconsistent navigation |
| 430 | 3. Missing error identification |
| 431 | 4. Timing without controls |
| 432 | 5. Missing landmark regions |
| 433 | |
| 434 | ## References |
| 435 | |
| 436 | - [WCAG 2.2 Quick Reference](https://www.w3.org/WAI/WCAG22/quickref/) |
| 437 | - [WAI-ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/) |
| 438 | - [Deque axe Rules](https://dequeuniversity.com/rules/axe/) |
| 439 | - [Web Quality Audit](../web-quality-audit/SKILL.md) |
| 440 | - [WCAG criteria reference](references/WCAG.md) |
| 441 | - [Accessibility code patterns](references/A11Y-PATTERNS.md) |