Accessibility & Inclusive Design
Accessibility is not a feature toggle or a final-pass audit — it is a property of correct UI construction. Build it in or rebuild it later. This is the bar for production: WCAG 2.2 Level AA, every screen, every platform. Automated tools catch ~30% of issues; the rest is keyboard, screen reader, and judgment. Treat this document as the working reference, not an aspiration.
1. WCAG 2.2 structure — POUR
WCAG is organized under four principles. Memorize them; every requirement maps to one.
| Principle |
Means |
Failure example |
| Perceivable |
Users can perceive the content (sight, sound, touch) |
Image with no alt; 2:1 contrast text |
| Operable |
Users can operate the UI (any input method) |
Control reachable by mouse only |
| Understandable |
Content and operation are predictable |
Error says "invalid input" with no fix |
| Robust |
Works with current and future assistive tech (AT) |
Custom <div> widget with no role/state |
Conformance levels: A (must — basics, keyboard, alt text), AA (the legal/industry target — contrast, reflow, focus, names), AAA (aspirational, not required wholesale — 7:1 contrast, sign language). Ship AA. Conformance is per-page and all-or-nothing: a page conforms only if every applicable success criterion (SC) at that level is met, with no AT-blocking content anywhere in the page or in any process (e.g. checkout) it belongs to.
The new WCAG 2.2 success criteria (added Oct 2023)
These are the ones teams miss because they post-date most a11y muscle memory. All are AA unless noted.
| SC |
Name |
Requirement |
| 2.4.11 |
Focus Not Obscured (Minimum) |
When an element gets keyboard focus, it is not entirely hidden by author content (sticky headers, cookie bars, chat widgets). |
| 2.4.12 |
Focus Not Obscured (Enhanced) |
AAA — focus indicator not obscured at all. |
| 2.4.13 |
Focus Appearance |
The focus indicator is at least as large as a 2px-thick perimeter of the component and has ≥3:1 contrast between focused/unfocused states. (AAA, but treat as the design target.) |
| 2.5.7 |
Dragging Movements |
Any drag operation has a single-pointer alternative (e.g. click-to-move, tap targets, up/down buttons) unless dragging is essential. |
| 2.5.8 |
Target Size (Minimum) |
Interactive targets are ≥24×24 CSS px, OR have ≥24px spacing to neighbors, OR are inline in a sentence. (AAA 2.5.5 wants 44px.) |
| 3.2.6 |
Consistent Help |
If help (contact, chat, FAQ link) appears on multiple pages, it appears in the same relative order each time. |
| 3.3.7 |
Redundant Entry |
Don't make users re-enter info they already gave in the same process — autofill it or let them select it. |
| 3.3.8 |
Accessible Authentication (Minimum) |
No cognitive function test (memorize/transcribe a password, solve a puzzle, identify objects) as the only way to authenticate. Allow password managers (don't block paste), email/OTP, passkeys, WebAuthn. |
One SC (4.1.1 Parsing) was removed in 2.2 — modern parsers handle duplicate IDs/malformed markup. Still write valid HTML; it just isn't a conformance line anymore.
2. Color & contrast
Color is the most-failed category in automated scans. Two independent rules: contrast ratios, and never-color-alone.
| Content |
Min ratio (AA) |
AAA |
| Normal text (<18.66px / <24px) |
4.5:1 |
7:1 |
| Large text (≥24px, or ≥18.66px bold) |
3:1 |
4.5:1 |
| Non-text (icons, input borders, focus rings, chart series, toggle states) — SC 1.4.11 |
3:1 |
— |
| Disabled controls / pure decoration |
exempt |
— |
/* FAIL: #999 on #fff = 2.85:1 — fails normal text */
.muted { color: #999; }
/* PASS: #767676 on #fff = 4.54:1 */
.muted { color: #767676; }
/* Non-text: a 1px #ddd input border is ~1.2:1 → invisible to low vision. Use ≥3:1. */
input { border: 1px solid #767676; }
- Never convey meaning by color alone (SC 1.4.1). Required field: add
* + text, not just a red label. Error state: icon + message, not just a red border. Chart series: pattern/label/direct annotation, not hue only. Link in body text: underline it, don't rely on blue.
- APCA (Accessible Perceptual Contrast Algorithm) is the contrast model proposed for WCAG 3.0. It scores lightness contrast (Lc, roughly 0–106) and accounts for font weight/size and polarity (dark-on-light vs light-on-dark) — fixing WCAG 2.x's known failures (it over-passes mid greys, mis-rates dark mode). Rough APCA targets: Lc 90 for body text, Lc 75 for larger/medium, Lc 60 for large headings, Lc 45 for non-text/large UI, Lc 30 is the floor for any text. Use APCA to design contrast, but conform to WCAG 2.2 ratios until WCAG 3 lands.
- Color blindness affects ~8% of men, ~0.5% of women. Types: protanopia/protanomaly (red-weak), deuteranopia/deuteranomaly (green-weak — most common), tritanopia (blue-weak, rare), achromatopsia (no color). Checks: red/green status pairs are the classic trap (use blue/orange, or add icons/text); never "click the green button"; test with a simulator (Chrome DevTools Rendering → Emulate vision deficiencies, Sim Daltonism on macOS, Stark).
3. Keyboard operability
If it works with a mouse but not a keyboard, it is broken. Keyboard access is the foundation for switch devices, voice control, and most screen-reader use.
- Everything operable by keyboard (SC 2.1.1).
Tab/Shift+Tab to move, Enter/Space to activate (Space for buttons/checkboxes, Enter for links), arrows within composite widgets, Esc to dismiss.
- No keyboard trap (SC 2.1.2). Focus must be able to leave any component via keyboard. The only intentional trap is a modal — and even then
Esc (or a close button) must release it.
- Logical focus order (SC 2.4.3) follows reading/DOM order. Don't reorder visually with CSS (
order, absolute positioning) in a way that diverges from DOM — it desyncs tab order from what's seen.
- Visible focus (SC 2.4.7) — non-negotiable. Never
outline: none without a replacement.
/* Use :focus-visible so mouse clicks don't show a ring but keyboard does. */
:focus-visible {
outline: 3px solid #1a73e8; /* ≥3:1 vs adjacent colors (2.4.11/2.4.13) */
outline-offset: 2px; /* ≥2px perimeter, fully visible */
}
/* Forbidden: */
button:focus { outline: none; } /* removes the only signal for keyboard users */
tabindex rules: 0 = in natural order; -1 = focusable by script only (not by Tab — for managing focus in widgets/dialogs); never use positive values — they hijack global order and create chaos.
- Skip link (SC 2.4.1) — first focusable element, jumps past the nav to
<main>. Visible on focus:
<a href="#main" class="skip-link">Skip to main content</a>
<!-- … nav … -->
<main id="main" tabindex="-1"> … </main>
.skip-link { position: absolute; left: -9999px; }
.skip-link:focus { left: 1rem; top: 1rem; /* on-screen, high contrast */ }
- Focus trapping in modals: on open, move focus into the dialog and remember the opener; constrain
Tab/Shift+Tab to focusable children (wrap from last→first and first→last); on close, return focus to the opener; close on Esc. Mark background inert with inert attribute (or aria-hidden="true" on siblings).
- Roving tabindex for composite widgets (toolbars, radio groups, menus, tabs, grids): exactly one child has
tabindex="0", the rest -1; arrow keys move the 0 and call .focus(). The whole widget is one tab stop. (Alternative: aria-activedescendant.)
- Shortcuts (SC 2.1.4): single-character shortcuts (e.g.
j/k) must be remappable, toggleable off, or active only on focus — or they fire while typing in fields and disrupt voice/AT users.
4. Screen readers & semantics
The first rule of ARIA: don't use ARIA. A native element with the right semantics beats any ARIA you'll bolt on. Reach for ARIA only when HTML genuinely can't express the pattern. Bad ARIA is worse than none.
Semantic HTML first
- Landmarks structure the page for AT navigation:
<header>, <nav>, <main> (one per page), <aside>, <footer>, <form>, <section aria-label>. Screen-reader users jump between them.
- Headings
<h1>–<h6> are the #1 navigation tool for SR users. One <h1> per page; never skip levels (no <h2>→<h4>); style with CSS, not heading rank. Don't fake a heading with bold text.
- Lists (
<ul>/<ol>/<dl>) announce item counts ("list, 5 items"). Group repeated items as a list.
- Buttons vs links:
<button> performs an action (submit, toggle, open dialog); <a href> navigates to a URL/location. A <div onclick> is not focusable, not keyboard-operable, and announces nothing. Never reinvent these.
<!-- WRONG: not focusable, no role, no keyboard, no announcement -->
<div class="btn"
<!-- RIGHT -->
<button type="button"
Accessible names (every interactive element needs one)
Precedence (later overrides earlier): aria-labelledby → aria-label → native (<label>, alt, text content) → title.
<!-- visible text IS the name — best -->
<button>Delete invoice</button>
<!-- icon-only button needs an explicit name -->
<button aria-label="Close dialog"><svg aria-hidden="true">…</svg></button>
<!-- point at existing visible text -->
<h2 id="prefs">Notification preferences</h2>
<section aria-labelledby="prefs">…</section>
<!-- extra detail, announced after the name -->
<input aria-describedby="pw-hint">
<p id="pw-hint">At least 12 characters.</p>
- Images: meaningful →
alt="describes content/function"; decorative → alt="" (empty, not missing — missing makes SRs read the filename); complex (charts) → short alt + long description nearby or via aria-describedby. Alt for a linked image describes the destination/action, not the picture.
- Live regions announce dynamic changes without moving focus:
aria-live="polite" (waits for a pause — status, "Saved", search-result counts), aria-live="assertive" (interrupts — errors, time-critical). Roles role="status" (=polite) and role="alert" (=assertive) are shorthand. The container must exist empty in the DOM before you inject text. Don't overuse assertive — it's rude and disorienting.
- State & properties must be exposed and kept in sync as the UI changes:
| ARIA |
Use |
aria-expanded="true/false" |
Disclosure, accordion, menu, combobox trigger |
aria-selected="true/false" |
Tabs, options, grid cells |
aria-checked="true/false/mixed" |
Custom checkbox/radio/switch (mixed = indeterminate) |
aria-disabled="true" |
Disabled but still in AT tree (unlike disabled, stays announced/focusable) |
aria-invalid="true" |
Field failing validation; pair with aria-describedby → error text |
aria-current="page/step/true" |
Current item in a set (nav, breadcrumb, pagination) |
aria-hidden="true" |
Remove from AT tree (decorative icons) — never on focusable content |
aria-pressed="true/false" |
Toggle button |
aria-controls / aria-owns |
Relate trigger to the thing it controls |
role only when HTML can't. role="tablist/tab/tabpanel", role="dialog", role="menu" etc. carry obligations: if you take the role, you owe the full keyboard interaction model and state management for it (see WAI-ARIA Authoring Practices Guide / APG). A half-built role="tab" is worse than three <a> links.
5. Forms
Forms are where most real-world a11y fails for users with cognitive, motor, or vision needs.
- Visible, persistent labels — programmatically associated. Placeholder is not a label: it vanishes on input, fails contrast, and is invisible to many AT setups.
<!-- WRONG -->
<input type="email" placeholder="Email">
<!-- RIGHT: explicit association via for/id -->
<label for="email">Email address</label>
<input id="email" type="email" name="email"
autocomplete="email" required aria-describedby="email-err">
<p id="email-err" role="alert" hidden>Enter a valid email, e.g. name@site.com</p>
- Errors (SC 3.3.1 identify, 3.3.3 suggestion): identify the field in text, describe what's wrong, suggest a fix. Bad: "Invalid." Good: "Phone must be 10 digits, e.g. 5551234567." Set
aria-invalid="true" and link the message with aria-describedby. On submit, move focus to the first error (or a summary role="alert" listing each error as a link to its field).
required native attribute conveys requirement to AT; aria-required="true" for custom controls. Pair with a visible "(required)" or marked optional — don't rely on * alone (announce it: include "required" in the label, or aria-label).
autocomplete (SC 1.3.5) with standard tokens (name, email, tel, street-address, cc-number, one-time-code) lets browsers/password managers/AT fill fields — critical for motor and cognitive users, and required by SC 3.3.8.
- Grouping: related controls (radio sets, address blocks, "shipping vs billing") go in
<fieldset> with a <legend> — the legend is announced with each control so the group context isn't lost.
- Inline validation a11y: validate on blur or submit, not on every keystroke (firing errors mid-typing is hostile to SR and cognitive users). Use
aria-live regions or role="alert" so the change is announced. Never disable the submit button as the only error feedback — SR users can't tell why it's dead.
6. Touch & motor
- Target size: WCAG 2.2 floor 24×24 CSS px (SC 2.5.8) or 24px spacing; design target 44×44 (iOS HIG) / 48×48 dp (Android Material) for thumbs and tremor/low-precision users. Small visual control? Expand the hit area with padding or a pseudo-element — the touch target, not the glyph, must be big.
- Spacing between targets prevents mis-taps; cramped toolbars/icon rows are a motor-accessibility failure even at 24px each.
- Dragging alternatives (SC 2.5.7): every drag-and-drop, slider, or reorder needs a single-pointer path (buttons, click-to-place, a numeric input, up/down). Don't ship drag-only.
- Gesture alternatives (SC 2.5.1 Pointer Gestures): no path-based or multipoint-only gestures. Pinch-zoom, two-finger rotate, swipe-along-a-path must have a simple single-tap/click equivalent. Carousel swipe → also give prev/next buttons.
- Pointer cancellation (SC 2.5.2): fire actions on
up/click, not down — so a user who presses the wrong target can slide off to cancel. No mousedown/touchstart triggers for destructive actions.
target-size is per-pointer, but mouse precision varies too (Parkinson's, RSI) — generous targets help everyone.
7. Motion & cognition
- Respect reduced motion (SC 2.3.3): heed the OS setting. Don't just slow animation — for vestibular disorders, remove parallax, large slides, zoom, and auto-spin; replace with a cut/fade.
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
- No seizure triggers (SC 2.3.1): nothing flashes more than 3 times per second, and avoid large/saturated-red flashes. Applies to GIFs, video, loaders, and canvas/WebGL.
- Auto-playing / moving content (SC 2.2.2): anything that moves, blinks, scrolls, or auto-updates for >5s needs a pause/stop/hide control (carousels, tickers, marquees, auto-advancing video).
- Plain language (relates to 3.1.5): short sentences, common words, expand acronyms on first use, front-load the action. Help dyslexia, ADHD, autism, low literacy, and non-native readers — and everyone under stress.
- Consistent navigation & identification (SC 3.2.3, 3.2.4): same nav order across pages; the same component named/iconned the same way everywhere. Predictability lowers cognitive load.
- Error prevention (SC 3.3.4, 3.3.6): for legal/financial/data-deleting actions, make submissions reversible, checked, or confirmed. "Are you sure?" + an undo window beats a silent destructive click.
- Timeouts (SC 2.2.1): warn before a session/timer expires and let users extend (≥20s warning, extendable ≥10×), unless the limit is essential (e.g. auction). Don't silently log people out mid-form.
- No reflow loss / supports zoom (SC 1.4.10): content reflows to a single column at 320px wide (≈400% zoom) with no horizontal scroll or clipping. Text resizes to 200% (SC 1.4.4) without breaking — use
rem/em, never block zoom (user-scalable=no is a violation), and respect text spacing overrides (SC 1.4.12).
8. Native platform accessibility
The principles are identical; the APIs differ. Lean on platform primitives — they ship correct semantics for free.
iOS — VoiceOver (UIKit/SwiftUI):
- Use standard controls (
UIButton, UISwitch) — they carry roles. Set accessibilityLabel (name), accessibilityHint (what happens), accessibilityValue (current value), and accessibilityTraits (.button, .header, .selected, .adjustable). SwiftUI: .accessibilityLabel(), .accessibilityHint(), .accessibilityAddTraits(), .accessibilityValue().
- Dynamic Type: use text styles (
.body, .headline) so text scales to the user's size; test at the largest accessibility sizes; don't hardcode font sizes or clip at large scales. Support Bold Text, Reduce Motion (UIAccessibility.isReduceMotionEnabled), and Increase Contrast.
- Group related elements (
accessibilityElement(children: .combine)), order with accessibilitySortPriority, mark decorative views hidden, expose custom actions via the rotor.
Android — TalkBack (Views/Compose):
contentDescription for non-text controls (null for decorative). Compose: Modifier.semantics { contentDescription = … }, Modifier.clickable (gives role/focus), Role.Button. Use stateDescription for toggles, heading() for headings, liveRegion for announcements.
- Honor font scale (
sp units, never dp/px for text), touch targets ≥48dp (minimumInteractiveComponentSize), and the Remove Animations setting. Use mergeDescendants to group, traversalIndex for order.
Desktop (macOS/Windows/Linux & Electron): native apps inherit the OS accessibility tree (NSAccessibility, UI Automation, AT-SPI) when using standard controls — label custom controls explicitly. Electron/web-in-shell apps follow all the web rules above; also ensure full keyboard menus, OS high-contrast themes, and OS reduce-motion are respected.
9. Testing
Automated tools find ~30% of issues — they cannot judge alt-text quality, focus order, name accuracy, or whether a custom widget makes sense. Manual testing is mandatory.
- Keyboard pass (do this first, every PR): unplug the mouse. Tab through — can you reach and operate everything? Is focus always visible and never obscured? Logical order? Can you escape every modal? Any trap?
- Screen reader pass: VoiceOver (macOS
Cmd+F5, iOS), NVDA (Windows, free), TalkBack (Android), JAWS (enterprise Windows). Navigate by heading, landmark, link, form field. Are names, roles, states announced correctly? Test in the OS-native pairing (VoiceOver+Safari, NVDA+Firefox/Chrome, TalkBack+Chrome) — combos differ.
- Automated scan: axe-core (DevTools extension or
@axe-core/playwright in CI), Lighthouse (a11y category), Pa11y, WAVE. Run in CI as a regression gate — but never as the whole strategy.
- Contrast: browser DevTools contrast inspector, WebAIM Contrast Checker, the APCA calculator, Stark/Polypane for whole-page sweeps.
- Zoom & reflow: browser zoom to 200% (text legible, nothing clipped) and 400% (single-column reflow, no horizontal scroll at 320px). Mobile pinch-zoom must work.
- Real assistive tech & real users: test with switch control, voice control (Voice Control/Dragon), magnification — and, where possible, with disabled users. Lived experience surfaces what tools and checklists miss.
10. Inclusive design beyond compliance
WCAG is the floor, not the goal. Inclusive design widens who can use the product and makes it better for everyone.
- Disability is a mismatch, not a trait — between a person and their context. It's a spectrum of permanent / temporary / situational: one-arm amputee (permanent), broken arm (temporary), holding a baby (situational) all benefit from one-handed operation. Design for the spectrum and you cover far more people than the "permanent" count suggests.
- Curb-cut effect: features built for disability help everyone — captions help in loud bars and quiet offices, voice control helps while driving, high contrast helps in sunlight, large targets help on the train. Accessibility is broad usability.
- Cognitive load is an accessibility concern: minimize steps and choices, chunk information, use progressive disclosure, keep layouts and labels consistent, write plainly, prevent and forgive errors. Helps ADHD, autism, anxiety, low literacy, fatigue.
- Neurodivergence: offer dark mode and reduced-motion; avoid auto-playing audio/video and aggressive animation; don't rely on metaphor/idiom; make state and progress explicit; respect literal interpretation; give clear undo. Predictability and control over sensory load matter more than polish.
11. The legal floor
- EAA (European Accessibility Act): from 28 June 2025, a broad class of consumer products and services (e-commerce, banking, e-books, transport, comms) sold in the EU must be accessible — effectively to EN 301 549 / WCAG 2.1 AA (moving toward 2.2). Real enforcement and penalties.
- US Section 508 (federal) and ADA (private sector, via case law) — DOJ's 2024 rule sets WCAG 2.1 AA for state/local government web and apps.
- Bottom line: target WCAG 2.2 AA and you satisfy the current legal regimes worldwide with headroom.
12. Common mistakes (the ones that actually ship)
| Mistake |
Why it breaks |
Fix |
| Placeholder as the label |
Vanishes on input, low contrast, no AT name |
Real <label for> |
<div>/<span> as a button |
Not focusable, no role, no keyboard |
<button> |
outline: none with no replacement |
Keyboard users lose all focus signal |
:focus-visible ring ≥2px, ≥3:1 |
| ARIA on top of broken HTML |
Conflicting/duplicate semantics confuse AT |
Fix the HTML; remove the ARIA |
role without the keyboard model |
Announces "tab" but arrows/state don't work |
Implement full APG pattern or use links |
| Color-only meaning (red = error) |
Invisible to color-blind/low-vision users |
Add icon + text |
| Contrast fails (#999, thin grey borders) |
Unreadable for low vision; fails 1.4.3/1.4.11 |
4.5:1 text, 3:1 non-text |
| Motion with no opt-out |
Triggers vestibular disorders/migraine |
prefers-reduced-motion + pause control |
| Icon-only control, no name |
Announces nothing / reads filename |
aria-label + aria-hidden on the glyph |
| Focus hidden behind sticky header |
Keyboard user can't see where they are |
scroll-margin, respect SC 2.4.11 |
Empty/missing alt confusion |
Missing → filename read aloud; wrong → noise |
alt="" decorative, descriptive otherwise |
aria-hidden on focusable content |
Element is operable but silent to AT |
Never hide focusable nodes |
| Auto-advancing carousel, no controls |
Moving content, fails 2.2.2 |
Pause/prev/next, stop on focus/hover |
| Skipped heading levels / multiple h1 |
Breaks SR document outline navigation |
One h1, sequential levels, style via CSS |
| Drag-only reorder, swipe-only nav |
Fails 2.5.7 / 2.5.1 for motor users |
Add single-pointer/button alternative |
| Blocking paste / puzzle CAPTCHA login |
Fails 3.3.8, defeats password managers |
Allow paste, OTP/passkeys, no cognitive test |
Working order for any screen: semantic HTML → accessible names → keyboard pass → visible focus → contrast → states/live regions → screen-reader pass → zoom/reflow → reduced motion → automated scan as the backstop. Build it in; don't bolt it on.
1---2name: accessibility-and-inclusive-design3description: Reference-grade guide to shipping WCAG 2.2 AA accessible, inclusively designed interfaces across web, native iOS/Android, and desktop — semantic-first markup, contrast/APCA, keyboard, screen readers, forms, touch/motor, motion/cognition, testing, and the legal floor (EAA/Section 508).4---5# Accessibility & Inclusive Design67Accessibility is not a feature toggle or a final-pass audit — it is a property of correct UI construction. Build it in or rebuild it later. This is the bar for production: **WCAG 2.2 Level AA**, every screen, every platform. Automated tools catch ~30% of issues; the rest is keyboard, screen reader, and judgment. Treat this document as the working reference, not an aspiration.89## 1. WCAG 2.2 structure — POUR1011WCAG is organized under four principles. Memorize them; every requirement maps to one.1213| Principle | Means | Failure example |14|---|---|---|15| **Perceivable** | Users can perceive the content (sight, sound, touch) | Image with no alt; 2:1 contrast text |16| **Operable** | Users can operate the UI (any input method) | Control reachable by mouse only |17| **Understandable** | Content and operation are predictable | Error says "invalid input" with no fix |18| **Robust** | Works with current and future assistive tech (AT) | Custom `<div>` widget with no role/state |1920**Conformance levels:** **A** (must — basics, keyboard, alt text), **AA** (the legal/industry target — contrast, reflow, focus, names), **AAA** (aspirational, not required wholesale — 7:1 contrast, sign language). Ship **AA**. Conformance is per-page and all-or-nothing: a page conforms only if **every** applicable success criterion (SC) at that level is met, with no AT-blocking content anywhere in the page or in any process (e.g. checkout) it belongs to.2122### The new WCAG 2.2 success criteria (added Oct 2023)2324These are the ones teams miss because they post-date most a11y muscle memory. All are AA unless noted.2526| SC | Name | Requirement |27|---|---|---|28| **2.4.11** | Focus Not Obscured (Minimum) | When an element gets keyboard focus, it is **not entirely hidden** by author content (sticky headers, cookie bars, chat widgets). |29| 2.4.12 | Focus Not Obscured (Enhanced) | AAA — focus indicator **not obscured at all**. |30| **2.4.13** | Focus Appearance | The focus indicator is **at least as large as a 2px-thick perimeter** of the component and has **≥3:1 contrast** between focused/unfocused states. (AAA, but treat as the design target.) |31| **2.5.7** | Dragging Movements | Any drag operation has a **single-pointer alternative** (e.g. click-to-move, tap targets, up/down buttons) unless dragging is essential. |32| **2.5.8** | Target Size (Minimum) | Interactive targets are **≥24×24 CSS px**, OR have ≥24px spacing to neighbors, OR are inline in a sentence. (AAA 2.5.5 wants 44px.) |33| **3.2.6** | Consistent Help | If help (contact, chat, FAQ link) appears on multiple pages, it appears in the **same relative order** each time. |34| **3.3.7** | Redundant Entry | Don't make users re-enter info they already gave in the same process — autofill it or let them select it. |35| **3.3.8** | Accessible Authentication (Minimum) | No **cognitive function test** (memorize/transcribe a password, solve a puzzle, identify objects) as the only way to authenticate. Allow password managers (don't block paste), email/OTP, passkeys, WebAuthn. |3637> One SC (4.1.1 Parsing) was **removed** in 2.2 — modern parsers handle duplicate IDs/malformed markup. Still write valid HTML; it just isn't a conformance line anymore.3839## 2. Color & contrast4041Color is the most-failed category in automated scans. Two independent rules: contrast ratios, and never-color-alone.4243| Content | Min ratio (AA) | AAA |44|---|---|---|45| Normal text (<18.66px / <24px) | **4.5:1** | 7:1 |46| Large text (≥24px, or ≥18.66px bold) | **3:1** | 4.5:1 |47| **Non-text** (icons, input borders, focus rings, chart series, toggle states) — SC 1.4.11 | **3:1** | — |48| Disabled controls / pure decoration | exempt | — |4950```css51/* FAIL: #999 on #fff = 2.85:1 — fails normal text */52.muted { color: #999; }53/* PASS: #767676 on #fff = 4.54:1 */54.muted { color: #767676; }55/* Non-text: a 1px #ddd input border is ~1.2:1 → invisible to low vision. Use ≥3:1. */56input { border: 1px solid #767676; }57```5859- **Never convey meaning by color alone** (SC 1.4.1). Required field: add `*` + text, not just a red label. Error state: icon + message, not just a red border. Chart series: pattern/label/direct annotation, not hue only. Link in body text: underline it, don't rely on blue.60- **APCA** (Accessible Perceptual Contrast Algorithm) is the contrast model proposed for WCAG 3.0. It scores **lightness contrast (Lc, roughly 0–106)** and accounts for font weight/size and polarity (dark-on-light vs light-on-dark) — fixing WCAG 2.x's known failures (it over-passes mid greys, mis-rates dark mode). Rough APCA targets: **Lc 90** for body text, **Lc 75** for larger/medium, **Lc 60** for large headings, **Lc 45** for non-text/large UI, **Lc 30** is the floor for any text. Use APCA to *design* contrast, but **conform to WCAG 2.2 ratios** until WCAG 3 lands.61- **Color blindness** affects ~8% of men, ~0.5% of women. Types: **protanopia/protanomaly** (red-weak), **deuteranopia/deuteranomaly** (green-weak — most common), **tritanopia** (blue-weak, rare), **achromatopsia** (no color). Checks: red/green status pairs are the classic trap (use blue/orange, or add icons/text); never "click the green button"; test with a simulator (Chrome DevTools Rendering → Emulate vision deficiencies, Sim Daltonism on macOS, Stark).6263## 3. Keyboard operability6465If it works with a mouse but not a keyboard, it is broken. Keyboard access is the foundation for switch devices, voice control, and most screen-reader use.6667- **Everything operable by keyboard** (SC 2.1.1). `Tab`/`Shift+Tab` to move, `Enter`/`Space` to activate (Space for buttons/checkboxes, Enter for links), arrows within composite widgets, `Esc` to dismiss.68- **No keyboard trap** (SC 2.1.2). Focus must be able to leave any component via keyboard. The *only* intentional trap is a modal — and even then `Esc` (or a close button) must release it.69- **Logical focus order** (SC 2.4.3) follows reading/DOM order. Don't reorder visually with CSS (`order`, absolute positioning) in a way that diverges from DOM — it desyncs tab order from what's seen.70- **Visible focus** (SC 2.4.7) — non-negotiable. Never `outline: none` without a replacement.7172```css73/* Use :focus-visible so mouse clicks don't show a ring but keyboard does. */74:focus-visible {75 outline: 3px solid #1a73e8; /* ≥3:1 vs adjacent colors (2.4.11/2.4.13) */76 outline-offset: 2px; /* ≥2px perimeter, fully visible */77}78/* Forbidden: */79button:focus { outline: none; } /* removes the only signal for keyboard users */80```8182- **`tabindex` rules:** `0` = in natural order; `-1` = focusable by script only (not by Tab — for managing focus in widgets/dialogs); **never use positive values** — they hijack global order and create chaos.83- **Skip link** (SC 2.4.1) — first focusable element, jumps past the nav to `<main>`. Visible on focus:8485```html86<a href="#main" class="skip-link">Skip to main content</a>87<!-- … nav … -->88<main id="main" tabindex="-1"> … </main>89```90```css91.skip-link { position: absolute; left: -9999px; }92.skip-link:focus { left: 1rem; top: 1rem; /* on-screen, high contrast */ }93```9495- **Focus trapping in modals:** on open, move focus into the dialog and remember the opener; constrain `Tab`/`Shift+Tab` to focusable children (wrap from last→first and first→last); on close, **return focus to the opener**; close on `Esc`. Mark background inert with `inert` attribute (or `aria-hidden="true"` on siblings).96- **Roving tabindex** for composite widgets (toolbars, radio groups, menus, tabs, grids): exactly **one** child has `tabindex="0"`, the rest `-1`; arrow keys move the `0` and call `.focus()`. The whole widget is one tab stop. (Alternative: `aria-activedescendant`.)97- **Shortcuts** (SC 2.1.4): single-character shortcuts (e.g. `j`/`k`) must be remappable, toggleable off, or active only on focus — or they fire while typing in fields and disrupt voice/AT users.9899## 4. Screen readers & semantics100101**The first rule of ARIA: don't use ARIA.** A native element with the right semantics beats any ARIA you'll bolt on. Reach for ARIA only when HTML genuinely can't express the pattern. Bad ARIA is worse than none.102103### Semantic HTML first104105- **Landmarks** structure the page for AT navigation: `<header>`, `<nav>`, `<main>` (one per page), `<aside>`, `<footer>`, `<form>`, `<section aria-label>`. Screen-reader users jump between them.106- **Headings** `<h1>`–`<h6>` are the #1 navigation tool for SR users. **One `<h1>` per page**; never skip levels (no `<h2>`→`<h4>`); style with CSS, not heading rank. Don't fake a heading with bold text.107- **Lists** (`<ul>`/`<ol>`/`<dl>`) announce item counts ("list, 5 items"). Group repeated items as a list.108- **Buttons vs links:** `<button>` performs an action (submit, toggle, open dialog); `<a href>` navigates to a URL/location. A `<div onclick>` is **not** focusable, not keyboard-operable, and announces nothing. Never reinvent these.109110```html111<!-- WRONG: not focusable, no role, no keyboard, no announcement -->112<div class="btn" onclick="save()">Save</div>113<!-- RIGHT -->114<button type="button" onclick="save()">Save</button>115```116117### Accessible names (every interactive element needs one)118119Precedence (later overrides earlier): `aria-labelledby` → `aria-label` → native (`<label>`, `alt`, text content) → `title`.120121```html122<!-- visible text IS the name — best -->123<button>Delete invoice</button>124<!-- icon-only button needs an explicit name -->125<button aria-label="Close dialog"><svg aria-hidden="true">…</svg></button>126<!-- point at existing visible text -->127<h2 id="prefs">Notification preferences</h2>128<section aria-labelledby="prefs">…</section>129<!-- extra detail, announced after the name -->130<input aria-describedby="pw-hint">131<p id="pw-hint">At least 12 characters.</p>132```133134- **Images:** meaningful → `alt="describes content/function"`; decorative → `alt=""` (empty, not missing — missing makes SRs read the filename); complex (charts) → short `alt` + long description nearby or via `aria-describedby`. Alt for a linked image describes the *destination/action*, not the picture.135- **Live regions** announce dynamic changes without moving focus: `aria-live="polite"` (waits for a pause — status, "Saved", search-result counts), `aria-live="assertive"` (interrupts — errors, time-critical). Roles `role="status"` (=polite) and `role="alert"` (=assertive) are shorthand. The container must exist **empty in the DOM before** you inject text. Don't overuse assertive — it's rude and disorienting.136- **State & properties** must be exposed and **kept in sync** as the UI changes:137138| ARIA | Use |139|---|---|140| `aria-expanded="true/false"` | Disclosure, accordion, menu, combobox trigger |141| `aria-selected="true/false"` | Tabs, options, grid cells |142| `aria-checked="true/false/mixed"` | Custom checkbox/radio/switch (`mixed` = indeterminate) |143| `aria-disabled="true"` | Disabled but still in AT tree (unlike `disabled`, stays announced/focusable) |144| `aria-invalid="true"` | Field failing validation; pair with `aria-describedby` → error text |145| `aria-current="page/step/true"` | Current item in a set (nav, breadcrumb, pagination) |146| `aria-hidden="true"` | Remove from AT tree (decorative icons) — **never on focusable content** |147| `aria-pressed="true/false"` | Toggle button |148| `aria-controls` / `aria-owns` | Relate trigger to the thing it controls |149150- **`role` only when HTML can't.** `role="tablist/tab/tabpanel"`, `role="dialog"`, `role="menu"` etc. carry obligations: if you take the role, you owe the full keyboard interaction model and state management for it (see WAI-ARIA Authoring Practices Guide / APG). A half-built `role="tab"` is worse than three `<a>` links.151152## 5. Forms153154Forms are where most real-world a11y fails for users with cognitive, motor, or vision needs.155156- **Visible, persistent labels** — programmatically associated. **Placeholder is not a label**: it vanishes on input, fails contrast, and is invisible to many AT setups.157158```html159<!-- WRONG -->160<input type="email" placeholder="Email">161<!-- RIGHT: explicit association via for/id -->162<label for="email">Email address</label>163<input id="email" type="email" name="email"164 autocomplete="email" required aria-describedby="email-err">165<p id="email-err" role="alert" hidden>Enter a valid email, e.g. name@site.com</p>166```167168- **Errors** (SC 3.3.1 identify, 3.3.3 suggestion): identify the field in text, describe what's wrong, **suggest a fix**. Bad: "Invalid." Good: "Phone must be 10 digits, e.g. 5551234567." Set `aria-invalid="true"` and link the message with `aria-describedby`. On submit, **move focus to the first error** (or a summary `role="alert"` listing each error as a link to its field).169- **`required`** native attribute conveys requirement to AT; `aria-required="true"` for custom controls. Pair with a visible "(required)" or marked optional — don't rely on `*` alone (announce it: include "required" in the label, or `aria-label`).170- **`autocomplete`** (SC 1.3.5) with standard tokens (`name`, `email`, `tel`, `street-address`, `cc-number`, `one-time-code`) lets browsers/password managers/AT fill fields — critical for motor and cognitive users, and required by SC 3.3.8.171- **Grouping:** related controls (radio sets, address blocks, "shipping vs billing") go in `<fieldset>` with a `<legend>` — the legend is announced with each control so the group context isn't lost.172- **Inline validation a11y:** validate on blur or submit, not on every keystroke (firing errors mid-typing is hostile to SR and cognitive users). Use `aria-live` regions or `role="alert"` so the change is announced. Never disable the submit button as the only error feedback — SR users can't tell why it's dead.173174## 6. Touch & motor175176- **Target size:** WCAG 2.2 floor **24×24 CSS px** (SC 2.5.8) or 24px spacing; design target **44×44 (iOS HIG)** / **48×48 dp (Android Material)** for thumbs and tremor/low-precision users. Small visual control? Expand the hit area with padding or a pseudo-element — the *touch target*, not the glyph, must be big.177- **Spacing** between targets prevents mis-taps; cramped toolbars/icon rows are a motor-accessibility failure even at 24px each.178- **Dragging alternatives** (SC 2.5.7): every drag-and-drop, slider, or reorder needs a single-pointer path (buttons, click-to-place, a numeric input, up/down). Don't ship drag-only.179- **Gesture alternatives** (SC 2.5.1 Pointer Gestures): **no path-based or multipoint-only gestures**. Pinch-zoom, two-finger rotate, swipe-along-a-path must have a simple single-tap/click equivalent. Carousel swipe → also give prev/next buttons.180- **Pointer cancellation** (SC 2.5.2): fire actions on **`up`/`click`, not `down`** — so a user who presses the wrong target can slide off to cancel. No `mousedown`/`touchstart` triggers for destructive actions.181- **`target-size` is per-pointer**, but mouse precision varies too (Parkinson's, RSI) — generous targets help everyone.182183## 7. Motion & cognition184185- **Respect reduced motion** (SC 2.3.3): heed the OS setting. Don't just slow animation — for vestibular disorders, remove parallax, large slides, zoom, and auto-spin; replace with a cut/fade.186187```css188@media (prefers-reduced-motion: reduce) {189 *, *::before, *::after {190 animation-duration: 0.01ms !important;191 animation-iteration-count: 1 !important;192 transition-duration: 0.01ms !important;193 scroll-behavior: auto !important;194 }195}196```197198- **No seizure triggers** (SC 2.3.1): nothing flashes **more than 3 times per second**, and avoid large/saturated-red flashes. Applies to GIFs, video, loaders, and canvas/WebGL.199- **Auto-playing / moving content** (SC 2.2.2): anything that moves, blinks, scrolls, or auto-updates for >5s needs a **pause/stop/hide** control (carousels, tickers, marquees, auto-advancing video).200- **Plain language** (relates to 3.1.5): short sentences, common words, expand acronyms on first use, front-load the action. Help dyslexia, ADHD, autism, low literacy, and non-native readers — and everyone under stress.201- **Consistent navigation & identification** (SC 3.2.3, 3.2.4): same nav order across pages; the same component named/iconned the same way everywhere. Predictability lowers cognitive load.202- **Error prevention** (SC 3.3.4, 3.3.6): for legal/financial/data-deleting actions, make submissions **reversible, checked, or confirmed**. "Are you sure?" + an undo window beats a silent destructive click.203- **Timeouts** (SC 2.2.1): warn before a session/timer expires and let users **extend** (≥20s warning, extendable ≥10×), unless the limit is essential (e.g. auction). Don't silently log people out mid-form.204- **No reflow loss / supports zoom** (SC 1.4.10): content reflows to a single column at **320px wide (≈400% zoom)** with no horizontal scroll or clipping. **Text resizes to 200%** (SC 1.4.4) without breaking — use `rem`/`em`, never block zoom (`user-scalable=no` is a violation), and respect **text spacing** overrides (SC 1.4.12).205206## 8. Native platform accessibility207208The principles are identical; the APIs differ. Lean on platform primitives — they ship correct semantics for free.209210**iOS — VoiceOver (UIKit/SwiftUI):**211- Use standard controls (`UIButton`, `UISwitch`) — they carry roles. Set `accessibilityLabel` (name), `accessibilityHint` (what happens), `accessibilityValue` (current value), and `accessibilityTraits` (`.button`, `.header`, `.selected`, `.adjustable`). SwiftUI: `.accessibilityLabel()`, `.accessibilityHint()`, `.accessibilityAddTraits()`, `.accessibilityValue()`.212- **Dynamic Type:** use text styles (`.body`, `.headline`) so text scales to the user's size; test at the largest accessibility sizes; don't hardcode font sizes or clip at large scales. Support **Bold Text**, **Reduce Motion** (`UIAccessibility.isReduceMotionEnabled`), and **Increase Contrast**.213- Group related elements (`accessibilityElement(children: .combine)`), order with `accessibilitySortPriority`, mark decorative views hidden, expose custom actions via the rotor.214215**Android — TalkBack (Views/Compose):**216- `contentDescription` for non-text controls (`null` for decorative). Compose: `Modifier.semantics { contentDescription = … }`, `Modifier.clickable` (gives role/focus), `Role.Button`. Use `stateDescription` for toggles, `heading()` for headings, `liveRegion` for announcements.217- Honor **font scale** (`sp` units, never `dp`/`px` for text), **touch targets ≥48dp** (`minimumInteractiveComponentSize`), and the **Remove Animations** setting. Use `mergeDescendants` to group, `traversalIndex` for order.218219**Desktop (macOS/Windows/Linux & Electron):** native apps inherit the OS accessibility tree (NSAccessibility, UI Automation, AT-SPI) when using standard controls — label custom controls explicitly. Electron/web-in-shell apps follow all the web rules above; also ensure full keyboard menus, OS high-contrast themes, and OS reduce-motion are respected.220221## 9. Testing222223Automated tools find ~**30%** of issues — they cannot judge alt-text quality, focus order, name accuracy, or whether a custom widget makes sense. **Manual testing is mandatory.**2242251. **Keyboard pass** (do this first, every PR): unplug the mouse. Tab through — can you reach **and operate** everything? Is focus **always visible** and never obscured? Logical order? Can you escape every modal? Any trap?2262. **Screen reader pass:** **VoiceOver** (macOS `Cmd+F5`, iOS), **NVDA** (Windows, free), **TalkBack** (Android), JAWS (enterprise Windows). Navigate by heading, landmark, link, form field. Are names, roles, states announced correctly? Test in the **OS-native pairing** (VoiceOver+Safari, NVDA+Firefox/Chrome, TalkBack+Chrome) — combos differ.2273. **Automated scan:** **axe-core** (DevTools extension or `@axe-core/playwright` in CI), **Lighthouse** (a11y category), Pa11y, WAVE. Run in CI as a regression gate — but never as the whole strategy.2284. **Contrast:** browser DevTools contrast inspector, WebAIM Contrast Checker, the APCA calculator, Stark/Polypane for whole-page sweeps.2295. **Zoom & reflow:** browser zoom to **200%** (text legible, nothing clipped) and **400%** (single-column reflow, no horizontal scroll at 320px). Mobile pinch-zoom must work.2306. **Real assistive tech & real users:** test with switch control, voice control (Voice Control/Dragon), magnification — and, where possible, with disabled users. Lived experience surfaces what tools and checklists miss.231232## 10. Inclusive design beyond compliance233234WCAG is the **floor**, not the goal. Inclusive design widens who can use the product and makes it better for everyone.235236- **Disability is a mismatch, not a trait** — between a person and their context. It's a spectrum of **permanent / temporary / situational**: one-arm amputee (permanent), broken arm (temporary), holding a baby (situational) all benefit from one-handed operation. Design for the spectrum and you cover far more people than the "permanent" count suggests.237- **Curb-cut effect:** features built for disability help everyone — captions help in loud bars and quiet offices, voice control helps while driving, high contrast helps in sunlight, large targets help on the train. Accessibility is broad usability.238- **Cognitive load** is an accessibility concern: minimize steps and choices, chunk information, use progressive disclosure, keep layouts and labels consistent, write plainly, prevent and forgive errors. Helps ADHD, autism, anxiety, low literacy, fatigue.239- **Neurodivergence:** offer dark mode and reduced-motion; avoid auto-playing audio/video and aggressive animation; don't rely on metaphor/idiom; make state and progress explicit; respect literal interpretation; give clear undo. Predictability and control over sensory load matter more than polish.240241## 11. The legal floor242243- **EAA (European Accessibility Act):** from **28 June 2025**, a broad class of consumer products and services (e-commerce, banking, e-books, transport, comms) sold in the EU must be accessible — effectively to **EN 301 549 / WCAG 2.1 AA** (moving toward 2.2). Real enforcement and penalties.244- **US Section 508** (federal) and **ADA** (private sector, via case law) — DOJ's 2024 rule sets **WCAG 2.1 AA** for state/local government web and apps.245- **Bottom line:** target **WCAG 2.2 AA** and you satisfy the current legal regimes worldwide with headroom.246247## 12. Common mistakes (the ones that actually ship)248249| Mistake | Why it breaks | Fix |250|---|---|---|251| Placeholder as the label | Vanishes on input, low contrast, no AT name | Real `<label for>` |252| `<div>`/`<span>` as a button | Not focusable, no role, no keyboard | `<button>` |253| `outline: none` with no replacement | Keyboard users lose all focus signal | `:focus-visible` ring ≥2px, ≥3:1 |254| ARIA on top of broken HTML | Conflicting/duplicate semantics confuse AT | Fix the HTML; remove the ARIA |255| `role` without the keyboard model | Announces "tab" but arrows/state don't work | Implement full APG pattern or use links |256| Color-only meaning (red = error) | Invisible to color-blind/low-vision users | Add icon + text |257| Contrast fails (#999, thin grey borders) | Unreadable for low vision; fails 1.4.3/1.4.11 | 4.5:1 text, 3:1 non-text |258| Motion with no opt-out | Triggers vestibular disorders/migraine | `prefers-reduced-motion` + pause control |259| Icon-only control, no name | Announces nothing / reads filename | `aria-label` + `aria-hidden` on the glyph |260| Focus hidden behind sticky header | Keyboard user can't see where they are | `scroll-margin`, respect SC 2.4.11 |261| Empty/missing `alt` confusion | Missing → filename read aloud; wrong → noise | `alt=""` decorative, descriptive otherwise |262| `aria-hidden` on focusable content | Element is operable but silent to AT | Never hide focusable nodes |263| Auto-advancing carousel, no controls | Moving content, fails 2.2.2 | Pause/prev/next, stop on focus/hover |264| Skipped heading levels / multiple h1 | Breaks SR document outline navigation | One h1, sequential levels, style via CSS |265| Drag-only reorder, swipe-only nav | Fails 2.5.7 / 2.5.1 for motor users | Add single-pointer/button alternative |266| Blocking paste / puzzle CAPTCHA login | Fails 3.3.8, defeats password managers | Allow paste, OTP/passkeys, no cognitive test |267268**Working order for any screen:** semantic HTML → accessible names → keyboard pass → visible focus → contrast → states/live regions → screen-reader pass → zoom/reflow → reduced motion → automated scan as the backstop. Build it in; don't bolt it on.