Accessibility
Web accessibility done right means your UI is navigable, understandable, and operable by people with disabilities — screen reader users, keyboard-only users, and people with low vision, motor, or cognitive impairments. This skill takes a screen-reader-first lens because it surfaces structural failures fastest, but low-vision users (contrast, zoom, reflow) and cognitive users matter just as much — in fact low-contrast text is the single most common barrier on the web, affecting ~84% of home pages. The WebAIM Million consistently finds ~96% of home pages carry detectable WCAG failures, and just six issue types — low-contrast text, missing alt text, missing form labels, empty links, empty buttons, and missing document language — account for ~96% of all detected failures and have topped the WebAIM Million findings every year they have been published (see link). Most are preventable with the right mental model.
The Core Mental Model
Screen readers linearise a 2D page into a 1D audio stream. A blind user never sees the whole page at once — they navigate sequentially by headings, landmarks, form fields, links, and interactive controls using keyboard shortcuts. Every decision you make should answer: "What will a screen reader announce, and does it make sense in isolation?"
The three rules that flow from this:
- Semantics over style — use native HTML elements (
<button>,<nav>,<h2>) before reaching for ARIA. Native elements come with free keyboard support, accessible names, and correct roles. - Context must travel with the element — a screen reader user navigating by tab or by links list sees elements stripped of their visual neighbours. Labels, descriptions, and states must be programmatically attached, not implied by proximity.
- Dynamic changes must be announced — screen readers only notice changes if focus moves to new content or a live region announces it. Silent DOM mutations are invisible to AT.
When Auditing Existing UI
Review in this priority order — fix critical issues before polishing low-impact ones:
| Priority | Category | WCAG Level | See | |----------|----------|------------|-----| | 1 | Accessible names (buttons, inputs, links) | A | references/aria-patterns.md | | 2 | Keyboard operability (all interactive elements) | A | references/focus-management.md | | 3 | Focus management (dialogs, SPAs, live regions) | A/AA | references/focus-management.md | | 4 | Semantic structure (headings, landmarks, lists) | A | references/wcag-checklist.md | | 5 | Form errors and validation | A/AA | references/common-fixes.md | | 6 | Colour contrast and visual states | AA | references/wcag-checklist.md | | 7 | Dynamic content announcements | AA | references/aria-patterns.md | | 8 | Images and media | A | references/wcag-checklist.md | | 9 | Motion, flashing, and timing (reduced-motion, seizure safety, auto-updating content) | A/AA | references/wcag-checklist.md |
Quote the exact failing snippet, name the WCAG criterion, and propose the smallest viable fix. Do not refactor unrelated code.
When Building New UI
The quick decision tree
Need an interactive control?
↓
Does a native HTML element do this? → YES → Use it. Done.
↓ NO
Use the correct ARIA role + required attributes + keyboard handler.
Adding dynamic content?
↓
Does focus move to the new content? → YES → No live region needed.
↓ NO
Is it a transient status (toast, cart count, form error)?
→ Use aria-live="polite" (or role="alert" for errors)
Opening a dialog/modal?
→ Trap focus inside. Restore focus to trigger on close.
→ See references/focus-management.md
Mandatory checks before shipping any interactive component
- [ ] Every input, select, textarea has an associated
<label>(not just placeholder) - [ ] Every button has an accessible name (text content,
aria-label, oraria-labelledby) - [ ] Every icon-only control has
aria-label; the icon hasaria-hidden="true" - [ ] Focus is visible on all interactive elements (never
outline: nonewithout a replacement) - [ ] Tab order is logical and matches visual order
- [ ] All pointer interactions have a keyboard equivalent
- [ ] No
tabindexgreater than 0 - [ ] Interactive targets are at least 24×24 CSS px, or spaced apart (WCAG 2.5.8)
- [ ] Non-essential motion respects
prefers-reduced-motion; nothing flashes more than three times per second (WCAG 2.3.1)
Five High-Impact Screen-Reader Failures (and their fixes)
These are the failures you will hit most through this skill's screen-reader lens. (The highest-volume failures site-wide — low-contrast text, missing alt — are covered under Colour and Contrast and Images below.)
- Icon-only button with no accessible name — announces bare "button"; needs
aria-labelon the button,aria-hidden="true"on the icon. - Input with no label — placeholder is not a label; needs an associated
<label for>. - div or span used as a button — no keyboard support, no role; use
<button>. - Error not linked to its field — visually adjacent text is silent; link with
aria-describedby+aria-invalid. - Dynamic content updated silently — DOM mutations are invisible to AT without a live region.
Before/after fixes for all five: references/common-fixes.md (fixes 1, 2, 5, 3, and 23 respectively).
Screen Reader Testing
Automated tools catch ~30–40% of accessibility issues. The rest require AT testing.
Minimum viable test matrix:
- NVDA + Firefox or Chrome (Windows) — free, strict, and one of the two most-used desktop readers
- VoiceOver + Safari (macOS/iOS) — the dominant reader across Apple platforms
- JAWS + Chrome for enterprise contexts — the other leading desktop reader
Core navigation patterns to test manually:
- Tab through all interactive elements — are names and roles announced correctly?
- Press
Hto navigate by headings — is the page structure logical? - Press
Dto navigate by landmarks — are regions clearly labelled? - Open and close any dialogs — does focus trap, then restore?
- Submit a form with errors — are error messages announced?
- Trigger any dynamic content update — is the change announced?
See references/screen-readers.md for NVDA/JAWS/VoiceOver commands, browse vs. forms mode, and testing scripts.
ARIA: The Rules
Rule 0: Don't use ARIA if native HTML solves it. Bad ARIA is worse than no ARIA.
Rule 1: aria-label and aria-labelledby provide the accessible name (what the element is).
Rule 2: aria-describedby provides supplementary description (what it does or needs).
Rule 3: aria-live="polite" for non-urgent updates; role="alert" (implicit assertive) for errors.
Rule 4: Live regions must exist in the DOM on page load — inject text into them, don't inject the region itself.
Rule 5: aria-hidden="true" removes from the AT tree completely. Never apply to focusable elements.
Full ARIA pattern library → references/aria-patterns.md
Visually Hidden Content
To show content to screen readers but hide it visually, use the .visually-hidden utility class — see references/common-fixes.md "The Visually Hidden Utility Class" for the canonical CSS.
Use for: skip links, supplementary link context ("Read more <span class="visually-hidden">about caching</span>"), icon button labels when aria-label is impractical for translation reasons.
Do not use for: content that sighted users need. Hiding meaningful content from one group creates disparity, not accessibility.
Colour and Contrast (WCAG AA)
| Content type | Minimum ratio | |---|---| | Body text (<18pt / 24px, and <14pt bold / 18.5px) | 4.5:1 | | Large text (≥18pt / 24px, or ≥14pt bold / 18.5px) | 3:1 | | UI components (borders, icons, focus rings) | 3:1 | | Placeholder text | 4.5:1 | | Disabled elements | Exempt |
Never convey information by colour alone — always pair with a shape, pattern, or text label.
Respect user colour preferences: support prefers-color-scheme, and test under Windows High Contrast / forced-colors: active rather than overriding it (never forced-color-adjust: none on meaningful content). See references/wcag-checklist.md.
Check exact foreground/background pairs from the accessibility skill
directory:
python3 scripts/contrast-check.py '#333333' '#ffffff'
python3 scripts/contrast-check.py '#767676' '#ffffff' --json
python3 scripts/contrast-check.py '#949494' '#ffffff' --target large-text
python3 scripts/contrast-check.py '#949494' '#ffffff' --target ui-component
Use --target normal-text (default), large-text, ui-component,
aaa-normal-text, or aaa-large-text to make the exit status follow the
relevant threshold. Do not round contrast values up. A measured 4.499:1 fails
a 4.5:1 requirement.
WCAG 2 AA (this table) is the sole conformance target. Its ratio maths is symmetric and ignores polarity, so it can over-rate some dark-mode pairings — if a passing pair still reads poorly on dark backgrounds, treat that as a design smell and sanity-check it with a perceptual tool (APCA). APCA is a candidate algorithm for the still-draft, undated WCAG 3 (it was even pulled from the July 2023 draft pending consensus) — a design aid only, never a compliance substitute.
Reference Files
| File | Contents |
|------|----------|
| references/screen-readers.md | NVDA/JAWS/VoiceOver commands, browse vs. forms mode, testing scripts per component type |
| references/aria-patterns.md | ARIA roles, labelling hierarchy, live region patterns, complex widget ARIA (combobox, tabs, tree) |
| references/focus-management.md | Modal focus trap, SPA route change focus, skip links, focus restoration patterns |
| references/wcag-checklist.md | WCAG 2.2 AA criterion-by-criterion checklist with pass/fail examples |
| references/common-fixes.md | Code-level fix templates for the 20 most common audit findings |
| scripts/contrast-check.py | Deterministic WCAG contrast ratio checker for foreground/background hex pairs |
| tests/test_contrast_check.py | Behavioural tests for the checker (ratios, exit codes, hex parsing) - uv run --with pytest -- pytest tests/ |