
Accessibility
- 51 installs
- 15 repo stars
- Updated August 1, 2026
- connorads/dotfiles
Audits and fixes web accessibility with a screen-reader-first lens for WCAG 2.2 AA, covering ARIA, keyboard navigation, and focus management.
About
Audits, implements, and fixes web accessibility with a screen-reader-first mental model for WCAG 2.2 AA compliance. A developer uses it when building or reviewing UI components, forms, dialogs, or any interactive element.
- Screen-reader-first lens covering NVDA, JAWS, and VoiceOver
- Targets WCAG 2.2 AA with ARIA patterns, keyboard navigation, and focus management
Accessibility by the numbers
- 51 all-time installs (skills.sh)
- Ranked #1,291 of 2,244 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/connorads/dotfiles --skill accessibilityAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 51 |
|---|---|
| repo stars | ★ 15 |
| Last updated | August 1, 2026 |
| Repository | connorads/dotfiles ↗ |
What it does
Audits and fixes web accessibility with a screen-reader-first lens for WCAG 2.2 AA, covering ARIA, keyboard navigation, and focus management.
Files
Accessibility
Web accessibility done right means your UI is navigable, understandable, and operable by people who cannot use a mouse — primarily those using screen readers (NVDA, JAWS, VoiceOver), keyboard-only users, and those with motor, cognitive, or visual impairments. The 2024 WebAIM Million report found 95.9% of home pages failing basic accessibility checks. Most failures 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:
1. 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. 2. 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. 3. 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 |
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.mdMandatory 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
---
The Five Most Common Failures (and their fixes)
1. Icon-only button with no accessible name
<!-- ❌ Screen reader announces: "button" -->
<button><svg>...</svg></button>
<!-- ✅ Screen reader announces: "Close, button" -->
<button aria-label="Close"><svg aria-hidden="true">...</svg></button>2. Input with no label
<!-- ❌ Screen reader announces: "edit text" -->
<input type="email" placeholder="Email" />
<!-- ✅ Screen reader announces: "Email address, edit text" -->
<label for="email">Email address</label>
<input id="email" type="email" />3. div or span used as a button
<!-- ❌ Not keyboard accessible, no role announced -->
<div onclick="save()">Save</div>
<!-- ✅ Free keyboard support, correct role -->
<button onclick="save()">Save</button>4. Form error not linked to field
<!-- ❌ Error visible but not associated with the field -->
<input id="email" type="email" />
<span>Please enter a valid email</span>
<!-- ✅ Screen reader announces error when field is focused -->
<input id="email" type="email"
aria-describedby="email-err"
aria-invalid="true" />
<span id="email-err" role="alert">Please enter a valid email</span>5. Dynamic content updated silently
<!-- ❌ Cart count updates, screen reader users never know -->
<span id="cart-count">3</span>
<!-- ✅ Announces "4 items in cart" when count changes -->
<span id="cart-count" aria-live="polite" aria-atomic="true">4 items in cart</span>---
Screen Reader Testing
Automated tools catch ~30–40% of accessibility issues. The rest require AT testing.
Minimum viable test matrix:
- NVDA + Chrome or Firefox (Windows) — covers ~66% of screen reader users
- VoiceOver + Safari (macOS/iOS) — covers Apple ecosystem
- Add JAWS + Chrome for enterprise contexts
Core navigation patterns to test manually: 1. Tab through all interactive elements — are names and roles announced correctly? 2. Press H to navigate by headings — is the page structure logical? 3. Press D to navigate by landmarks — are regions clearly labelled? 4. Open and close any dialogs — does focus trap, then restore? 5. Submit a form with errors — are error messages announced? 6. 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:
.visually-hidden:not(:focus):not(:active) {
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}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 / <14pt bold) | 4.5:1 |
| Large text (≥18pt or ≥14pt bold) | 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.
---
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 |
ARIA Patterns Reference
ARIA (Accessible Rich Internet Applications) exposes semantics to assistive technologies when native HTML doesn't provide them. The golden rule: no ARIA is better than bad ARIA. Native HTML is always preferred.
---
The Accessible Name Computation
Screen readers compute a control's accessible name using this priority order (highest wins):
1. aria-labelledby (references another element's text) 2. aria-label (inline string) 3. Native <label> element (for form controls) 4. title attribute (tooltip — use as last resort; inconsistent support) 5. Text content of the element itself (buttons, links) 6. alt attribute (images) 7. placeholder attribute (never use as sole label — disappears on input)
Accessible description (supplementary, announced after the name) comes from aria-describedby.
<!-- Name: "Email address" | Description: "We'll never share your email" -->
<label for="email">Email address</label>
<input id="email"
aria-describedby="email-hint"
type="email" />
<span id="email-hint">We'll never share your email with anyone.</span>---
Labelling Techniques
aria-label (string directly on element)
Use when no visible label exists or the visible label is insufficient.
<button aria-label="Close dialog">✕</button>
<nav aria-label="Main navigation">...</nav>
<section aria-label="Recommended products">...</section>Caveat: aria-label is not translated by automated tools. For multilingual sites, prefer aria-labelledby pointing to visible translated text.
aria-labelledby (points to visible text)
Use when the label already exists visually elsewhere on the page.
<h2 id="products-heading">Products</h2>
<ul aria-labelledby="products-heading">...</ul>
<!-- Composing a name from multiple elements -->
<span id="first">John</span> <span id="last">Smith</span>
<button aria-labelledby="first last">Profile</button>
<!-- Announced: "John Smith, button" -->aria-describedby (supplementary description)
Announced after the name, typically on focus. Use for: hints, error messages, format instructions.
<input id="pw"
type="password"
aria-describedby="pw-requirements"
aria-invalid="false" />
<p id="pw-requirements">
Must be 8+ characters with a number and symbol.
</p>Labelling groups with fieldset/legend
Always use for radio groups and checkbox groups.
<fieldset>
<legend>Notification preferences</legend>
<label><input type="radio" name="notify" value="email" /> Email</label>
<label><input type="radio" name="notify" value="sms" /> SMS</label>
<label><input type="radio" name="notify" value="none" /> None</label>
</fieldset>
<!-- Each radio announced: "Email, radio button, 1 of 3, Notification preferences, group" -->---
ARIA Roles
Landmark Roles (navigation regions)
| HTML Element | Implicit ARIA Role | Use For |
|---|---|---|
<header> | banner | Site-level header (once per page) |
<nav> | navigation | Navigation regions |
<main> | main | Primary page content (once per page) |
<footer> | contentinfo | Site-level footer |
<aside> | complementary | Related but non-essential content |
<section> with accessible name | region | Named content region |
<form> with accessible name | form | Form region |
<search> | search | Search landmark (HTML 5.x) |
Multiple <nav> elements must be distinguished with aria-label:
<nav aria-label="Main">...</nav>
<nav aria-label="Footer">...</nav>Widget Roles (interactive elements)
Only use when native HTML doesn't provide the semantics. All widget roles require keyboard handling.
`role="button"` — use only when you cannot use <button>.
<div role="button" tabindex="0"
onkeydown="if(e.key==='Enter'||e.key===' ') activate(e)">
Save
</div>Better: just use <button>.
`role="checkbox"` (custom)
<div role="checkbox"
aria-checked="false"
tabindex="0"
aria-labelledby="tos-label">
</div>
<span id="tos-label">Accept terms of service</span>Keyboard: Space toggles aria-checked. Enter is not required but common.
`role="switch"` — for boolean toggles (on/off semantics, not checked/unchecked)
<button role="switch" aria-checked="true">Dark mode</button>`role="combobox"` — autocomplete/select widget (complex — see ARIA APG)
`role="dialog"` — modal overlay
<div role="dialog"
aria-modal="true"
aria-labelledby="dialog-title">
<h2 id="dialog-title">Confirm deletion</h2>
...
</div>aria-modal="true" tells screen readers to restrict reading to the dialog. Still implement JS focus trap — aria-modal alone is insufficient.
`role="alertdialog"` — modal requiring immediate response (confirm/deny)
`role="alert"` — implicit aria-live="assertive". Use for errors and urgent messages.
<div role="alert">Your session is about to expire. Save your work.</div>`role="status"` — implicit aria-live="polite". Use for success messages, counts.
<div role="status">File uploaded successfully.</div>---
ARIA States and Properties
Dynamic states (change at runtime)
| Attribute | Values | Use When |
|---|---|---|
aria-expanded | true / false | Toggle buttons controlling collapsible regions |
aria-selected | true / false | Active tab, selected option in listbox |
aria-checked | true / false / mixed | Custom checkboxes, switches |
aria-pressed | true / false | Toggle buttons |
aria-disabled | true / false | Non-interactive elements (prefer HTML disabled on form controls) |
aria-invalid | true / false / grammar / spelling | Form field with validation error |
aria-busy | true / false | Loading states (partial support — pair with live region) |
aria-hidden | true | Remove from accessibility tree entirely |
Relationship properties
| Attribute | Purpose |
|---|---|
aria-controls | Identifies element controlled by this one (e.g., toggle → panel) |
aria-owns | Declares parent-child relationship not in DOM order |
aria-haspopup | Indicates a popup (menu, listbox, tree, grid, dialog) will appear |
Toggle button pattern
<button aria-expanded="false" aria-controls="nav-menu">
Menu
</button>
<ul id="nav-menu" hidden>
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
</ul>
<script>
btn.addEventListener('click', () => {
const expanded = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', !expanded);
menu.hidden = expanded;
});
</script>---
Live Regions
Live regions announce changes to screen readers without moving focus. Use sparingly — overuse causes noise.
Choosing the right pattern
| Scenario | Pattern | Urgency |
|---|---|---|
| Success toast ("Saved") | role="status" or aria-live="polite" | Low |
| Cart count update | aria-live="polite" + aria-atomic="true" | Low |
| Search result count | aria-live="polite" | Low |
| Error after form submit | role="alert" or aria-live="assertive" | High |
| Session timeout warning | role="alertdialog" (dialog, not live region) | High |
| Constantly updating ticker | Move focus, or provide separate "summary" button | Avoid live region |
Implementation rules
1. Live regions must be in the DOM on page load — inject text into them, not the region itself 2. Start empty — if pre-populated, the initial content won't be announced 3. Wait ≥2s before populating a dynamically injected live region (browser needs to register it) 4. Keep messages concise — they're announced once and cannot be replayed 5. `aria-atomic="true"` — announces the entire region content, not just the changed part (use for counts: "4 items in cart" not just "4")
<!-- In HTML on page load — always empty initially -->
<div id="status-live" role="status" aria-live="polite" aria-atomic="true"></div>
<div id="error-live" role="alert" aria-live="assertive" aria-atomic="true"></div>
<!-- In JS — inject text to trigger announcement -->
document.getElementById('status-live').textContent = '3 items in cart';
// To re-trigger the same message (clear first):
statusEl.textContent = '';
requestAnimationFrame(() => { statusEl.textContent = '3 items in cart'; });When NOT to use live regions
- Focus moves to the new content — moving focus is already an announcement
- Modal opens — focus trap + role="dialog" handles this
- Page navigates — document title change + focus to
<main>or<h1>announces it - Inline form errors —
aria-describedby+aria-invalidon the field is sufficient; the error is read when the field receives focus. Userole="alert"only for a summary error region at the top of a form.
---
Complex Widget Patterns
For complex interactive widgets, follow the ARIA Authoring Practices Guide (APG) patterns exactly. Partial ARIA implementation is worse than none.
Tabs (ARIA tab pattern)
<div role="tablist" aria-label="Account sections">
<button role="tab" aria-selected="true" aria-controls="profile-panel" id="profile-tab">Profile</button>
<button role="tab" aria-selected="false" aria-controls="billing-panel" id="billing-tab" tabindex="-1">Billing</button>
</div>
<div role="tabpanel" id="profile-panel" aria-labelledby="profile-tab">...</div>
<div role="tabpanel" id="billing-panel" aria-labelledby="billing-tab" hidden>...</div>Keyboard: Tab enters the tab list, arrow keys navigate between tabs (not Tab), Tab again moves to tab panel.
Menu Button pattern
<button aria-haspopup="menu" aria-expanded="false" id="actions-btn">
Actions ▾
</button>
<ul role="menu" aria-labelledby="actions-btn" hidden>
<li role="menuitem">Edit</li>
<li role="menuitem">Delete</li>
</ul>Keyboard: Enter/Space opens, arrow keys navigate items, Escape closes, first-letter navigation optional.
Accordion
<h3>
<button aria-expanded="false" aria-controls="section1-body">
Section 1
</button>
</h3>
<div id="section1-body" hidden>
Content
</div>No custom ARIA role needed — the <button> inside a heading is sufficient.
---
aria-hidden Pitfalls
aria-hidden="true" removes the element and all descendants from the accessibility tree.
Never apply to:
- Focusable elements (
<button>,<a>,<input>) — keyboard focus will land there with no announcement - The currently focused element
- Parents of focusable elements
Correct uses:
<!-- Decorative icon -->
<button>Delete <svg aria-hidden="true">...</svg></button>
<!-- Decorative separator -->
<hr aria-hidden="true" />
<!-- Background content behind modal -->
<main aria-hidden="true">...</main> <!-- Remove when modal closes -->Hiding modal background: When a modal is open, the background content should be aria-hidden="true" so screen reader users can't navigate outside the dialog. Remove aria-hidden when the modal closes. Many focus trap libraries handle this automatically.
Common Fixes Reference
Ready-to-use code fixes for the 20 most frequent accessibility audit findings. Each fix is minimal — it targets the specific issue without rewriting surrounding code.
---
1. Icon-only button missing accessible name
<!-- ❌ Before -->
<button class="icon-btn">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M19 11H7.83l4.88-4.88..."/>
</svg>
</button>
<!-- ✅ After -->
<button class="icon-btn" aria-label="Back to previous page">
<svg aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M19 11H7.83l4.88-4.88..."/>
</svg>
</button>Note: focusable="false" on SVG prevents IE/Edge from adding SVG to tab order.
---
2. Input without associated label
<!-- ❌ Before - placeholder is not a label -->
<input type="search" placeholder="Search products..." />
<!-- ✅ After - Option A: Visible label -->
<label for="product-search">Search products</label>
<input id="product-search" type="search" />
<!-- ✅ After - Option B: Visually hidden label (when design can't accommodate visible label) -->
<label for="product-search" class="visually-hidden">Search products</label>
<input id="product-search" type="search" placeholder="e.g. shoes, jackets" />
<!-- ✅ After - Option C: aria-label (use when label element is impractical) -->
<input type="search" aria-label="Search products" placeholder="e.g. shoes, jackets" />---
3. Form error not associated with field
<!-- ❌ Before -->
<div class="field">
<label for="email">Email</label>
<input id="email" type="email" class="error" />
<span class="error-msg">Please enter a valid email address.</span>
</div>
<!-- ✅ After -->
<div class="field">
<label for="email">Email</label>
<input
id="email"
type="email"
class="error"
aria-invalid="true"
aria-describedby="email-error"
/>
<span id="email-error" class="error-msg">Please enter a valid email address.</span>
</div>For inline errors, aria-invalid + aria-describedby is sufficient — the error is read when the field is focused. No live region needed unless you also want to announce it immediately.
---
4. Form error summary at top of page
<!-- ✅ Error summary that receives focus after failed submit -->
<div
id="error-summary"
role="alert"
tabindex="-1"
class="error-summary"
>
<h2>There are 2 errors in this form</h2>
<ul>
<li><a href="#email">Email: Please enter a valid email address</a></li>
<li><a href="#password">Password: Must be at least 8 characters</a></li>
</ul>
</div>// After form submit validation
document.getElementById('error-summary').focus();The role="alert" announces immediately. Moving focus there ensures keyboard users land on the summary. Links in the list allow jumping to each errored field.
---
5. div or span used as interactive control
<!-- ❌ Before - no keyboard access, no role -->
<div class="btn" onclick="handleSave()">Save</div>
<!-- ✅ After - native element -->
<button class="btn" onclick="handleSave()">Save</button>
<!-- ✅ After - if you truly cannot change the element (third-party library) -->
<div
class="btn"
role="button"
tabindex="0"
onclick="handleSave()"
onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();handleSave();}"
>
Save
</div>---
6. Ambiguous link text
<!-- ❌ Before - "Read more" repeated 10× on page, all link to different articles -->
<article>
<h3>Product update</h3>
<p>We've improved performance...</p>
<a href="/updates/2024-q4">Read more</a>
</article>
<!-- ✅ After - Option A: Descriptive link text -->
<a href="/updates/2024-q4">Read more about our Q4 product update</a>
<!-- ✅ After - Option B: Visually hidden context -->
<a href="/updates/2024-q4">
Read more <span class="visually-hidden">about our Q4 product update</span>
</a>
<!-- ✅ After - Option C: aria-label -->
<a href="/updates/2024-q4" aria-label="Read more: Q4 product update">Read more</a>---
7. Link opens in new tab without warning
<!-- ❌ Before -->
<a href="https://partner.example.com" target="_blank">Partner site</a>
<!-- ✅ After - warn visually and programmatically -->
<a href="https://partner.example.com" target="_blank" rel="noopener">
Partner site
<svg aria-hidden="true" class="icon-external">...</svg>
<span class="visually-hidden">(opens in new tab)</span>
</a>---
8. Select/radio group missing group label
<!-- ❌ Before - individual labels but no group label -->
<div class="field-group">
<label><input type="radio" name="contact" value="email" /> Email</label>
<label><input type="radio" name="contact" value="phone" /> Phone</label>
</div>
<!-- ✅ After - fieldset + legend groups them -->
<fieldset>
<legend>Preferred contact method</legend>
<label><input type="radio" name="contact" value="email" /> Email</label>
<label><input type="radio" name="contact" value="phone" /> Phone</label>
</fieldset>For a group of related checkboxes, same pattern applies.
---
9. Required fields not indicated programmatically
<!-- ❌ Before - visual asterisk only -->
<label for="name">Name <span class="required">*</span></label>
<input id="name" type="text" />
<!-- ✅ After - aria-required + explained in legend/instruction -->
<p>Fields marked with <span aria-hidden="true">*</span><span class="visually-hidden">an asterisk</span> are required.</p>
<label for="name">Name <span aria-hidden="true">*</span></label>
<input id="name" type="text" required aria-required="true" />required (HTML) and aria-required="true" (ARIA) both work. HTML required also triggers native validation. Use aria-required when you've implemented custom validation.
---
10. Custom toggle/accordion without ARIA state
<!-- ❌ Before - state not announced -->
<button class="accordion-trigger" onclick="toggle(this)">
How do I reset my password?
</button>
<div class="accordion-content" hidden>...</div>
<!-- ✅ After -->
<button
class="accordion-trigger"
aria-expanded="false"
aria-controls="faq-1-content"
onclick="toggle(this)"
>
How do I reset my password?
</button>
<div id="faq-1-content" class="accordion-content" hidden>...</div>function toggle(trigger) {
const expanded = trigger.getAttribute('aria-expanded') === 'true';
trigger.setAttribute('aria-expanded', !expanded);
const contentId = trigger.getAttribute('aria-controls');
document.getElementById(contentId).hidden = expanded;
}---
11. Navigation missing accessible name when multiple navs exist
<!-- ❌ Before - two <nav>s, indistinguishable by screen reader -->
<nav><!-- Main navigation --></nav>
<nav><!-- Breadcrumb --></nav>
<!-- ✅ After -->
<nav aria-label="Main">...</nav>
<nav aria-label="Breadcrumb" aria-current="page">...</nav>If there's only one <nav>, no label is needed (the landmark role is sufficient).
---
12. Carousel/slider not keyboard navigable
<!-- ✅ Accessible carousel skeleton -->
<section aria-label="Featured products" aria-roledescription="carousel">
<div
role="group"
aria-roledescription="slide"
aria-label="Slide 1 of 3"
aria-hidden="false"
>
<!-- Slide content -->
</div>
<button aria-label="Previous slide">‹</button>
<button aria-label="Next slide">›</button>
<div aria-live="polite" class="visually-hidden" id="carousel-live"></div>
</section>function changeSlide(newIndex) {
// Update aria-hidden on slides
slides.forEach((slide, i) => {
slide.setAttribute('aria-hidden', i !== newIndex);
});
// Announce change
document.getElementById('carousel-live').textContent =
`Showing slide ${newIndex + 1} of ${slides.length}`;
}Auto-advancing carousels must have a pause control (WCAG 2.2.2).
---
13. Toast notification not announced
<!-- In HTML on page load (empty) -->
<div id="toast-announcer" role="status" aria-live="polite" aria-atomic="true" class="visually-hidden"></div>
<!-- Visual toast can be whatever -->
<div id="toast" class="toast" hidden>Item added to cart</div>function showToast(message) {
// Show visual toast
const toast = document.getElementById('toast');
toast.textContent = message;
toast.hidden = false;
setTimeout(() => toast.hidden = true, 3000);
// Announce to screen readers
const announcer = document.getElementById('toast-announcer');
announcer.textContent = '';
requestAnimationFrame(() => {
announcer.textContent = message;
});
}---
14. Table missing headers or scope
<!-- ❌ Before - data table without headers -->
<table>
<tr><td>Alice</td><td>Engineering</td><td>Senior</td></tr>
<tr><td>Bob</td><td>Design</td><td>Junior</td></tr>
</table>
<!-- ✅ After - column headers -->
<table>
<caption>Team members</caption>
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Department</th>
<th scope="col">Level</th>
</tr>
</thead>
<tbody>
<tr>
<td>Alice</td>
<td>Engineering</td>
<td>Senior</td>
</tr>
</tbody>
</table>For complex tables with both row and column headers:
<th scope="row">Row header</th>
<th scope="col">Column header</th>---
15. Images of text (charts, infographics)
<!-- ❌ Before - screen reader gets no information -->
<img src="infographic.png" alt="Infographic" />
<!-- ✅ After - Option A: Detailed alt -->
<img src="infographic.png"
alt="2024 user growth: 45,000 users in Q1, 62,000 in Q2, 78,000 in Q3, 95,000 in Q4." />
<!-- ✅ After - Option B: Linked long description -->
<figure>
<img src="infographic.png" alt="2024 user growth infographic. Full data in table below." />
<figcaption>
<details>
<summary>Full data table</summary>
<table><!-- Full data table --></table>
</details>
</figcaption>
</figure>---
16. focus outline removed globally
/* ❌ Before - common reset that breaks keyboard accessibility */
* { outline: none; }
*:focus { outline: none; }
/* ✅ After - remove for mouse users only, keep for keyboard */
*:focus:not(:focus-visible) {
outline: none;
}
*:focus-visible {
outline: 3px solid #005fcc;
outline-offset: 2px;
}---
17. Lazy-loaded images missing alt
<!-- ❌ Before - alt="" on functional images means "decorative" to screen readers -->
<img
src="placeholder.jpg"
data-src="product.jpg"
alt=""
class="lazy"
/>
<!-- ✅ After - include meaningful alt from the start -->
<img
src="placeholder.jpg"
data-src="product.jpg"
alt="Blue leather wallet, front view"
class="lazy"
/>---
18. Custom select/dropdown not keyboard navigable
For custom selects, prefer a native <select> styled with CSS over a custom implementation. If a custom implementation is necessary, follow the ARIA Authoring Practices Guide combobox pattern which is complex. Minimal version:
<div class="custom-select">
<button
id="select-btn"
aria-haspopup="listbox"
aria-expanded="false"
aria-labelledby="select-label select-btn"
>
<span id="select-label">Choose country</span>
<span id="selected-value">Select...</span>
</button>
<ul
role="listbox"
aria-labelledby="select-label"
hidden
>
<li role="option" aria-selected="false">United Kingdom</li>
<li role="option" aria-selected="false">France</li>
<li role="option" aria-selected="true">Germany</li>
</ul>
</div>Keyboard requirements: Enter/Space opens, arrow keys move between options, Enter selects, Escape closes. Home/End move to first/last. Type-ahead search is expected.
Consider using Radix UI, Headless UI, or React Aria — all provide fully accessible implementations.
---
19. Sticky header obscuring focused elements (WCAG 2.4.11)
/* ✅ Scroll offset when jumping to anchor/focused elements */
html {
scroll-padding-top: 80px; /* Match sticky header height */
}
/* Or per element */
#main-content {
scroll-margin-top: 80px;
}For focus visibility specifically:
:focus-visible {
scroll-margin-top: 80px; /* Ensure focused element scrolls into view below sticky header */
}---
20. Skip link not working for screen reader users
<!-- ✅ Complete skip link implementation -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<header>...</header>
<main id="main-content" tabindex="-1">
<!-- tabindex="-1" makes it focusable programmatically when skip link activates -->
...
</main>.skip-link {
position: absolute;
top: -40px;
left: 8px;
background: #000000;
color: #ffffff;
padding: 8px 16px;
text-decoration: none;
font-weight: bold;
z-index: 9999;
border-radius: 0 0 4px 4px;
}
.skip-link:focus {
top: 0;
}The tabindex="-1" on <main> allows browsers to move focus to it when the skip link is activated, ensuring the next Tab press doesn't go back to the navigation.
---
The Visually Hidden Utility Class
Required for many of the above patterns:
.visually-hidden:not(:focus):not(:active) {
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}Use for:
- Extra link context ("Read more <span class="visually-hidden">about our returns policy</span>")
- Icon button supplement (
aria-labelis preferred, but this works for translation needs) - Form labels hidden by design but required for AT
- Skip link text when not focused
- Live region containers
Do NOT use display:none or visibility:hidden — those hide from AT too.
Focus Management Reference
Focus management is the programmatic control of keyboard focus — moving it, trapping it, and restoring it. This is the area most commonly broken in modern web apps, especially SPAs.
---
The Core Focus Contract
1. When a UI state change hides the current focused element, move focus to somewhere logical 2. When a dialog opens, focus moves inside it 3. When a dialog closes, focus returns to what opened it 4. When a SPA navigates, focus moves to the new page content 5. When focus would be lost (deleted element), move it to the nearest logical parent
Breaking this contract means keyboard and screen reader users lose their place on the page with no way to recover other than reloading.
---
Modal Dialogs
The complete accessible modal checklist
- [ ]
role="dialog"or use native<dialog>element - [ ]
aria-modal="true"(restricts virtual cursor to dialog in supporting screen readers) - [ ]
aria-labelledbypointing to the dialog's heading, oraria-label - [ ] Focus moves into dialog on open (to dialog container, first heading, or first interactive element)
- [ ] Focus is trapped within dialog while open (Tab and Shift+Tab cycle within)
- [ ]
Escapekey closes the dialog - [ ] Focus returns to the trigger element on close
- [ ] Background content is
aria-hidden="true"while dialog is open
Vanilla JS focus trap implementation
function trapFocus(element) {
const focusableSelectors = [
'a[href]', 'button:not([disabled])',
'input:not([disabled])', 'select:not([disabled])',
'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"])'
].join(', ');
function getFocusableElements() {
return [...element.querySelectorAll(focusableSelectors)]
.filter(el => !el.closest('[hidden]'));
}
function handleKeyDown(e) {
if (e.key !== 'Tab') return;
const focusable = getFocusableElements();
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
// Shift+Tab from first → go to last
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
// Tab from last → go to first
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
element.addEventListener('keydown', handleKeyDown);
// Return cleanup function
return () => element.removeEventListener('keydown', handleKeyDown);
}
// Usage
function openModal(modalEl, triggerEl) {
const cleanup = trapFocus(modalEl);
// Make background inert
document.getElementById('app-root').setAttribute('aria-hidden', 'true');
// Focus first focusable element in dialog
const firstFocusable = modalEl.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
firstFocusable?.focus();
function handleEscape(e) {
if (e.key === 'Escape') closeModal();
}
document.addEventListener('keydown', handleEscape);
function closeModal() {
cleanup();
document.removeEventListener('keydown', handleEscape);
document.getElementById('app-root').removeAttribute('aria-hidden');
triggerEl.focus(); // Restore focus to trigger
}
return closeModal;
}Using native <dialog> element (recommended)
The native <dialog> element handles focus trapping, Escape key, and aria-modal behaviour automatically in modern browsers.
<dialog id="confirm-dialog" aria-labelledby="dialog-title">
<h2 id="dialog-title">Confirm deletion</h2>
<p>Are you sure you want to delete this item?</p>
<button id="confirm-btn">Delete</button>
<button id="cancel-btn" autofocus>Cancel</button>
</dialog>const dialog = document.getElementById('confirm-dialog');
const trigger = document.getElementById('delete-trigger');
trigger.addEventListener('click', () => {
dialog.showModal(); // Opens as modal, traps focus, handles Escape
});
document.getElementById('cancel-btn').addEventListener('click', () => {
dialog.close();
trigger.focus(); // Native <dialog> does NOT auto-restore focus
});
dialog.addEventListener('close', () => {
trigger.focus(); // Always restore focus on close
});`autofocus` attribute on the cancel button is correct for destructive dialogs — prevents accidental confirmation. For non-destructive dialogs, focus the first input or the dialog container itself.
React implementation
import { useEffect, useRef } from 'react';
function Modal({ isOpen, onClose, title, children, triggerRef }) {
const dialogRef = useRef(null);
useEffect(() => {
if (!isOpen) return;
// Focus first element inside dialog
const focusable = dialogRef.current?.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
focusable?.focus();
// Restore focus on close
return () => {
triggerRef.current?.focus();
};
}, [isOpen]);
if (!isOpen) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
ref={dialogRef}
>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
);
}Libraries: focus-trap-react, @radix-ui/react-dialog, react-aria (Adobe) all handle this correctly. Prefer them over custom implementations.
---
SPA Route Changes
Traditional page loads reset focus to the top of the document automatically. SPAs don't. Without intervention, focus remains on the clicked link or button after navigation, and screen reader users have no idea the page changed.
Pattern 1: Focus the <h1> on route change
// React Router v6 example
import { useLocation } from 'react-router-dom';
import { useEffect, useRef } from 'react';
function PageTitle({ title }) {
const h1Ref = useRef(null);
const location = useLocation();
useEffect(() => {
// Update document title
document.title = `${title} - My App`;
// Move focus to page heading
// tabIndex="-1" makes it focusable programmatically without entering tab order
h1Ref.current?.focus();
}, [location.pathname]);
return <h1 ref={h1Ref} tabIndex={-1}>{title}</h1>;
}Pattern 2: Focus a live region announcing the new page
// Announce route change without moving visible focus
const announcer = document.getElementById('route-announcer');
router.on('navigate', (route) => {
announcer.textContent = '';
requestAnimationFrame(() => {
announcer.textContent = `Navigated to ${route.title}`;
});
});<!-- In app shell - present on all pages -->
<div id="route-announcer"
aria-live="assertive"
aria-atomic="true"
class="visually-hidden">
</div>Pattern 3: Skip link to main content (always include this)
<!-- First element in <body> -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<header>...</header>
<main id="main-content">...</main>.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #000;
color: #fff;
padding: 8px;
z-index: 100;
text-decoration: none;
}
.skip-link:focus {
top: 0; /* Slides into view only on keyboard focus */
}---
Focus Visibility
Never remove focus outlines without providing a visible replacement. Keyboard users depend on the focus indicator to know where they are.
WCAG 2.2 Focus Appearance (2.4.11, 2.4.12 — AA)
- Focused element must have a focus indicator with at least 3:1 contrast ratio against adjacent colours
- Focus indicator area must be at least the perimeter of the element
/* Minimal compliant focus style */
:focus-visible {
outline: 3px solid #005fcc;
outline-offset: 2px;
}
/* Remove focus ring only for mouse users (not keyboard) */
:focus:not(:focus-visible) {
outline: none;
}:focus-visible applies only when the browser determines focus came from keyboard or AT, not mouse click. This prevents the ring appearing on button click for mouse users while keeping it for keyboard users.
Custom focus style example
button:focus-visible {
outline: none;
box-shadow: 0 0 0 3px white, 0 0 0 5px #005fcc; /* White gap for contrast on coloured buttons */
}---
tabindex
| Value | Effect |
|---|---|
tabindex="0" | Makes non-interactive element keyboard focusable and adds to natural tab order |
tabindex="-1" | Focusable programmatically (via .focus()) but not via Tab key |
tabindex="1" or higher | Never use. Creates unpredictable tab order that breaks for users |
<!-- Make a modal container focusable so focus.() works on open -->
<div role="dialog" tabindex="-1" aria-labelledby="title">...</div>
<!-- Custom widget container — arrow keys navigate internally, not Tab -->
<ul role="listbox" tabindex="0">
<li role="option" tabindex="-1">Option 1</li>
<li role="option" tabindex="-1">Option 2</li>
</ul>The roving tabindex pattern (used for widgets like tab lists, menus, radio groups): only the active item has tabindex="0", all others have tabindex="-1". Arrow keys move between items and update which has tabindex="0". Tab moves focus out of the widget entirely.
---
Focus After Dynamic Content Changes
| Scenario | Focus destination |
|---|---|
| Modal opens | First focusable element inside modal, or modal container (tabindex="-1") |
| Modal closes | Element that triggered the modal |
| Inline confirmation appears | The confirmation element (with tabindex="-1") |
| Form error summary appears | Error summary container (with tabindex="-1") |
| SPA navigation | Page <h1> or <main> (with tabindex="-1") |
| Accordion opens | Leave focus on the accordion toggle (do not move) |
| Infinite scroll loads | Leave focus in place; announce count via live region |
| Toast/notification appears | Leave focus in place; use live region |
| Deleted item in list | Next item in list, or the list container if last item |
---
Inert Attribute (Modern Alternative)
The inert attribute (2023+, good browser support) makes an element and all its descendants unfocusable, non-interactive, and hidden from the accessibility tree simultaneously — a cleaner alternative to aria-hidden + tabindex manipulation.
function openModal(modal, appRoot) {
appRoot.inert = true; // Locks out background completely
modal.removeAttribute('inert');
modal.querySelector('button')?.focus();
}
function closeModal(modal, appRoot, triggerEl) {
modal.inert = true;
appRoot.inert = false;
triggerEl.focus();
}Check browser support before using in production. Polyfill available: wicg-inert.
---
Common Focus Bugs
Bug: Focus lost to `<body>` after dynamic removal
// ❌ Element removed, focus disappears
item.remove();
// ✅ Move focus before removing
const nextSibling = item.nextElementSibling || item.previousElementSibling || list;
nextSibling.focus();
item.remove();Bug: Modal opens, focus not moved
// ❌ Modal appears, screen reader user is still on the trigger
modal.classList.remove('hidden');
// ✅ Move focus after modal renders
modal.classList.remove('hidden');
modal.querySelector('h2, button, input').focus();Bug: Focus trapped in modal after `display:none` toggle
// ❌ Modal hidden but focus trap still active
modal.style.display = 'none';
// ✅ Clean up trap before hiding
cleanup(); // remove event listeners
modal.style.display = 'none';
triggerEl.focus();Bug: Skip link not visible on focus (common mistake)
/* ❌ Hidden with display:none — not focusable at all */
.skip-link { display: none; }
.skip-link:focus { display: block; }
/* ✅ Visually positioned off-screen, slides in on focus */
.skip-link { position: absolute; top: -40px; }
.skip-link:focus { top: 0; }Screen Readers Reference
Practical knowledge for testing with and building for the three dominant screen readers. 2024 WebAIM survey: NVDA 65.6%, JAWS 60.5%, VoiceOver 44.1% (users commonly use multiple).
---
How Screen Readers Work
Screen readers maintain a virtual buffer — a linearised copy of the page's accessibility tree. In browse mode (also called virtual cursor / reading mode), users navigate this buffer with single-key shortcuts without interacting with the live DOM. In forms mode (interaction mode / focus mode), keystrokes go to the focused control rather than the screen reader.
Understanding this is critical: keyboard commands like H for next heading only work in browse mode. Inside a form field or custom widget, those keys type characters. Screen readers switch modes automatically at certain elements, and announce the switch with a sound cue. Users can also switch manually.
Implication for developers: Custom widgets that expect arrow key input (menus, sliders, grids) must declare their roles so the screen reader knows to switch to forms mode. Widgets that don't declare roles trap users in browse mode where arrow keys navigate the buffer instead of the widget.
---
NVDA (NonVisual Desktop Access)
Free, open-source. Best with Firefox or Chrome on Windows. Strict code interpreter — exposes exactly what's in markup.
Starting NVDA
- Download from nvaccess.org (free)
- Press
Insert + Nto open NVDA menu - Press
Ctrlto silence speech mid-sentence - Adjust speech rate:
Insert + N→ Preferences → Settings → Speech
Browse Mode Commands
| Action | Command |
|---|---|
| Next heading | H |
| Previous heading | Shift+H |
| Heading level 1–6 | 1–6 |
| Next landmark | D |
| Next link | K |
| Next form field | F |
| Next button | B |
| Next table | T |
| Elements list (all) | NVDA+F7 |
| Read current line | NVDA+Up |
| Read from cursor | NVDA+Down |
| Toggle forms mode | NVDA+Space |
Forms Mode Commands
| Action | Command |
|---|---|
| Next form field | Tab |
| Activate button | Enter or Space |
| Select from combo box | Alt+Down, then arrow keys |
| Re-read field label | NVDA+Tab |
| Exit forms mode | Escape |
Table Navigation (Browse Mode)
Ctrl+Alt+Right— next cell in rowCtrl+Alt+Left— previous cell in rowCtrl+Alt+Down— cell below (with column header announced)Ctrl+Alt+Up— cell above
NVDA announces column and row headers automatically when <th> elements are correctly marked up.
Testing Workflow with NVDA
1. Open page in Firefox or Chrome 2. Press Ctrl+Home to go to top 3. Press NVDA+F7 → open elements list → switch to Headings view — is page structure logical? 4. Press NVDA+F7 → switch to Landmarks view — are regions present and labelled? 5. Tab through all interactive elements — does each get announced with role + name? 6. Test any forms: enter data, trigger errors, check that errors are announced on field focus 7. Test any dialogs: open, check focus moves inside, check Escape closes and focus returns to trigger 8. Check any live regions: trigger dynamic updates, verify announcements
---
JAWS (Job Access With Speech)
Paid (£90/yr or £1,100 perpetual). Enterprise standard. Uses heuristics to "repair" bad markup — may pass things NVDA fails. For auditing, this means JAWS passing is not proof of correctness; NVDA is the stricter reference.
JAWS offers 40-minute free evaluation sessions without purchase.
Key Differences from NVDA
- JAWS has smart navigation that can infer context from visual layout when ARIA is missing
- Uses a different virtual buffer implementation — occasional differences in announcement order
- Better compatibility with legacy enterprise applications (MS Office, older CRMs)
Insertis the JAWS modifier (same as NVDA, but settings key isInsert+J)
Browse Mode Commands (same navigation keys as NVDA)
| Action | Command |
|---|---|
| All headings list | Insert+F6 |
| All links list | Insert+F7 |
| All form fields list | Insert+F5 |
| Read current element | Insert+Tab |
| Say all | Insert+Down |
Forms Mode
JAWS enters forms mode automatically when focused on an input. You hear a "chime" entering forms mode and a lower "chime" leaving. Press Enter or Space on a form field to activate forms mode manually if needed.
---
VoiceOver (macOS and iOS)
Built into all Apple devices. Best with Safari. More lenient with markup errors than NVDA.
Enabling VoiceOver
- macOS:
Cmd+F5or System Settings → Accessibility → VoiceOver - iOS: Settings → Accessibility → VoiceOver → toggle on
VoiceOver uses a rotor (gesture or VO+U on Mac) to switch navigation modes — headings, links, form controls, landmarks, etc.
Core macOS Commands
| Action | Command |
|---|---|
| VO modifier (hold) | Ctrl+Option (VO) |
| Next element | VO+Right |
| Previous element | VO+Left |
| Interact with element | VO+Shift+Down |
| Stop interacting | VO+Shift+Up |
| Activate | VO+Space |
| Open rotor | VO+U |
| Read from beginning | VO+A |
| Headings list | VO+U, then arrow to "Headings" |
Rotor Navigation (most efficient for users)
Press VO+U to open the rotor wheel. Arrow left/right to select category (Headings, Links, Form Controls, Landmarks, etc.). Arrow up/down to move through items in that category. Press Enter to jump.
iOS VoiceOver Gestures
- Swipe right/left — next/previous element
- Double tap — activate
- Two-finger swipe up — read from top
- Rotor — rotate two fingers to switch navigation mode, swipe up/down to navigate
---
Screen Reader + Browser Pairings
Testing with the correct browser pairing matters — some accessibility APIs work differently across combinations.
| Screen Reader | Best Browser | Notes |
|---|---|---|
| NVDA | Firefox, Chrome | Both good; Firefox has slightly better ARIA support |
| JAWS | Chrome, IE (legacy) | Chrome is primary for modern testing |
| VoiceOver macOS | Safari | Safari has the most complete AT support on Mac |
| VoiceOver iOS | Safari | Always use Safari on iOS |
| Narrator (Windows) | Edge | Built into Windows, edge cases with complex ARIA |
| TalkBack (Android) | Chrome | Default Android screen reader |
---
Navigation Patterns Real Users Rely On
From WebAIM screen reader surveys, the most common navigation strategies on a new page:
1. Headings first — users press H repeatedly to understand page structure and jump to sections. If headings are missing or skipped, users lose navigation entirely. 2. Forms mode — entering any input triggers forms mode; Tab navigates between fields. Users rely on labels being correctly associated to know what each field is. 3. Links list — Insert+F7 (JAWS/NVDA) opens all links in a list. Every link must make sense out of context. "Click here" and "Read more" are useless in this view. 4. Landmarks — D jumps between regions. Pages without landmarks force linear reading of the entire page to find content. 5. Table navigation — when a table is announced, users use Ctrl+Alt+Arrow to navigate cell-by-cell, expecting headers to be re-announced with each cell.
---
Component-Level Testing Scripts
Testing a button
1. Tab to the button 2. What is announced? Should be: [name], button 3. Press Enter and Space — both should activate 4. If icon-only: is aria-label present? Is the SVG aria-hidden="true"?
Testing a form
1. Tab to first field — should announce: [label], edit (or [label], required, edit) 2. Submit with empty required fields — do errors appear? 3. Tab to an errored field — should announce: [label], [error message], invalid data, edit 4. Check error message is not just colour change
Testing a modal dialog
1. Activate the trigger 2. Does focus move inside the dialog? (Should announce dialog role + name) 3. Tab through all elements — does focus wrap within the dialog? 4. Press Escape — does dialog close and focus return to the trigger? 5. Try tabbing outside — can focus escape the dialog? (It shouldn't)
Testing a dropdown menu
1. Press the toggle button — should announce: [name], button, expanded 2. Navigate items with arrow keys (in custom menus) or Tab 3. Select item — should announce selection and close menu 4. Press Escape — should close menu and return focus to trigger
Testing a tab panel
1. Tab to the tab list 2. Arrow keys move between tabs (not Tab key — Tab should move to tab panel content) 3. Enter or Space activates a tab 4. The active tab should have aria-selected="true" announced 5. Tab from the tab moves focus to the panel content
Testing live content
1. Trigger the update (add to cart, submit form, filter results) 2. Wait — within 1–2 seconds the screen reader should announce the change 3. Verify the announcement is concise and meaningful (not just raw data)
---
Common Announcement Patterns
What screen readers announce for well-implemented elements:
<button>Save changes</button>
→ "Save changes, button"
<input id="name" required /> <label for="name">Full name</label>
→ "Full name, required, edit text"
<input aria-invalid="true" aria-describedby="name-err" />
<span id="name-err">Name must be at least 2 characters</span>
→ "Full name, Name must be at least 2 characters, invalid data, required, edit text"
<a href="/about">About us</a>
→ "About us, link"
<nav aria-label="Main">...</nav>
→ "Main, navigation" (announced when entering landmark)
<h2>Products</h2>
→ "Products, heading level 2"
<img alt="Bar chart showing 25% increase in Q2 sales" />
→ "Bar chart showing 25% increase in Q2 sales, image"
<img alt="" /> (decorative)
→ (nothing announced)WCAG 2.2 AA Checklist
Criterion-by-criterion reference for WCAG 2.2 Level A and AA. Level AAA included where commonly implemented. Focus is on practical pass/fail examples, not abstract definitions.
Conformance target for most projects: WCAG 2.2 AA. This is the legal standard for ADA, Section 508 (US federal), European Accessibility Act (EEA, June 2025+).
---
Perceivable
1.1 Text Alternatives
1.1.1 Non-text Content (A) All non-text content must have a text alternative.
<!-- ✅ Informative image -->
<img src="chart.png" alt="Bar chart: Q2 revenue up 25% vs Q1" />
<!-- ✅ Decorative image -->
<img src="divider.png" alt="" role="presentation" />
<!-- ✅ Icon with function -->
<button><img src="search.png" alt="Search" /></button>
<!-- ✅ SVG icon — hide SVG, label the button -->
<button aria-label="Search">
<svg aria-hidden="true" focusable="false">...</svg>
</button>
<!-- ❌ Missing alt -->
<img src="product.jpg" />
<!-- ❌ Filename as alt -->
<img src="product.jpg" alt="product.jpg" />
<!-- ❌ "image of" / "photo of" — redundant -->
<img src="dog.jpg" alt="Photo of a dog" />Alt text should convey the purpose and meaning, not describe the picture literally. "Bar chart showing Q2 revenue" is better than "A chart with blue and orange bars."
---
1.2 Time-based Media
1.2.1 Audio-only and Video-only (A)
- Pre-recorded audio: provide a text transcript
- Pre-recorded video (no audio): provide audio description or text equivalent
1.2.2 Captions — Pre-recorded (A) All pre-recorded video with audio must have synchronised captions. Auto-generated captions alone (YouTube, etc.) are insufficient — they must be reviewed for accuracy.
1.2.3 Audio Description (A) Pre-recorded video must have audio description or a text alternative where visual content conveys information not in the audio track.
1.2.5 Audio Description (AA) All pre-recorded video must have audio description (same as 1.2.3, stricter).
---
1.3 Adaptable
1.3.1 Info and Relationships (A) Structure and meaning must be programmatically determinable.
<!-- ✅ Heading hierarchy -->
<h1>Page title</h1>
<h2>Section</h2>
<h3>Subsection</h3>
<h2>Another section</h2>
<!-- ❌ Heading used for styling, not structure -->
<h4 style="font-size: 1.5rem">This should be an h2</h4>
<!-- ✅ Table with headers -->
<table>
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Role</th>
</tr>
</thead>
<tbody>
<tr>
<td>Alice</td>
<td>Engineer</td>
</tr>
</tbody>
</table>
<!-- ❌ Layout table presenting data -->
<table>
<tr><td><b>Name</b></td><td><b>Role</b></td></tr>
<tr><td>Alice</td><td>Engineer</td></tr>
</table>1.3.2 Meaningful Sequence (A) Reading order in the DOM must be logical. CSS absolute positioning should not create a DOM order that contradicts the visual/logical reading order.
1.3.3 Sensory Characteristics (A) Instructions must not rely on shape, colour, size, location, or sound alone.
❌ "Click the red button to continue"
✅ "Click the Continue button (highlighted in red) to proceed"1.3.4 Orientation (AA) Content must not be restricted to portrait or landscape only, unless essential.
1.3.5 Identify Input Purpose (AA) Form inputs collecting personal data must use autocomplete attributes.
<input type="email" autocomplete="email" />
<input type="tel" autocomplete="tel" />
<input type="text" autocomplete="given-name" />
<input type="text" autocomplete="family-name" />
<input type="text" autocomplete="street-address" />---
1.4 Distinguishable
1.4.1 Use of Colour (A) Colour must not be the only visual means of conveying information.
❌ Required fields marked only by red label colour
✅ Required fields marked with asterisk (*) + aria-required="true" + visible legend explaining the mark
❌ Error state shown only by red border
✅ Error state: red border + error icon + error text message1.4.2 Audio Control (A) Any audio that plays automatically for more than 3 seconds must have a mechanism to pause or stop it.
1.4.3 Contrast Minimum (AA) Text contrast ratios:
- Normal text (< 18pt / < 14pt bold): 4.5:1
- Large text (≥ 18pt or ≥ 14pt bold): 3:1
Tools: WebAIM Contrast Checker, browser DevTools, Colour Contrast Analyser (desktop app).
1.4.4 Resize Text (AA) Text must resize to 200% without loss of content or functionality. Do not use px for font sizes in ways that prevent scaling. Avoid fixed-height containers that clip text.
1.4.5 Images of Text (AA) Use actual text rather than images of text. Exception: logos.
1.4.10 Reflow (AA) Content must reflow at 400% zoom (equivalent to 320px viewport width) without horizontal scrolling. Exception: content requiring two-dimensional layout (maps, data tables).
1.4.11 Non-text Contrast (AA) UI components and graphical objects must have 3:1 contrast against adjacent colours.
- Form input borders must meet 3:1 against background
- Focus indicators must meet 3:1
- Icon-only buttons must meet 3:1 for the icon
1.4.12 Text Spacing (AA) Text must remain readable when users apply all of the following simultaneously:
- Line height: 1.5× the font size
- Letter spacing: 0.12× the font size
- Word spacing: 0.16× the font size
- Paragraph spacing: 2× the font size
1.4.13 Content on Hover or Focus (AA) When additional content appears on hover or focus (tooltips, sub-menus):
- The content is dismissable (e.g., Escape closes it)
- The pointer can move over the additional content without it disappearing
- The content stays until pointer leaves, focus moves, or user dismisses
<!-- ✅ Tooltip that persists when hovered -->
<button aria-describedby="tooltip">?</button>
<div role="tooltip" id="tooltip">More information about this field</div>---
Operable
2.1 Keyboard Accessible
2.1.1 Keyboard (A) All functionality must be operable via keyboard. This means:
- Every interactive element must be reachable by Tab
- All mouse-triggered actions must have keyboard equivalents
- Custom drag-and-drop must have a keyboard alternative
2.1.2 No Keyboard Trap (A) Users must always be able to move focus away from any component using standard keys (Tab, arrow keys, Escape). Exception: modals — which intentionally trap focus but must release on Escape.
2.1.4 Character Key Shortcuts (A) Single character keyboard shortcuts that fire on keydown/keypress must be reconfigurable, disableable, or only active when the component has focus.
---
2.4 Navigable
2.4.1 Bypass Blocks (A) There must be a mechanism to skip repetitive navigation. Minimum: a "Skip to main content" skip link as the first focusable element.
<a href="#main" class="skip-link">Skip to main content</a>
...
<main id="main">...</main>Also satisfied by ARIA landmark regions that allow screen reader users to jump by landmark.
2.4.2 Page Titled (A) Every page must have a descriptive <title>. Format: [Page name] — [Site name]. In SPAs, update document.title on every route change.
2.4.3 Focus Order (A) Tab order must follow a meaningful sequence. DOM order should match visual reading order.
2.4.4 Link Purpose (In Context) (A) Link text must make sense in isolation or in context of its surrounding paragraph/heading. "Click here" and "Read more" fail without context.
<!-- ❌ Ambiguous out of context -->
<a href="/report.pdf">Click here</a>
<!-- ✅ Descriptive -->
<a href="/report.pdf">Download Q4 Annual Report (PDF, 2.3MB)</a>
<!-- ✅ Context via visually hidden text -->
<a href="/report.pdf">
Read more <span class="visually-hidden">about our Q4 Annual Report</span>
</a>
<!-- ✅ Context via aria-label -->
<a href="/report.pdf" aria-label="Download Q4 Annual Report PDF">Read more</a>2.4.6 Headings and Labels (AA) Headings and form labels must describe their topic or purpose.
2.4.7 Focus Visible (AA) All focusable elements must have a visible focus indicator. Never outline: none without a replacement.
2.4.11 Focus Not Obscured — Minimum (AA) (new in 2.2) A focused element must not be entirely hidden by sticky headers, cookie banners, or other overlaid content.
2.4.12 Focus Not Obscured — Enhanced (AAA) (new in 2.2) The entire focused element must be visible (no partial obscuring).
2.4.13 Focus Appearance (AA) (new in 2.2) Focus indicator must:
- Have an area of at least the perimeter of the element × 2px
- Have a contrast ratio of at least 3:1 against adjacent colours
---
2.5 Input Modalities
2.5.1 Pointer Gestures (A) Multipoint or path-based gestures (pinch, swipe, drag) must have a single-pointer alternative.
2.5.2 Pointer Cancellation (A) For single-pointer interactions, at least one of: no down-event trigger, abort/undo mechanism, or up-event trigger with reversibility.
2.5.3 Label in Name (A) Visible label text must match or be contained within the accessible name.
<!-- ❌ Accessible name doesn't contain visible text -->
<button aria-label="Submit the registration form">Register</button>
<!-- ✅ Accessible name contains visible text -->
<button aria-label="Register for the conference">Register</button>Voice control users say the visible label to activate controls. If the accessible name differs, the control won't respond.
2.5.4 Motion Actuation (A) Functionality triggered by device motion must have a UI alternative, and motion response must be disableable.
2.5.7 Dragging Movements (AA) (new in 2.2) Any drag-and-drop functionality must have a single-pointer alternative.
2.5.8 Target Size — Minimum (AA) (new in 2.2) Interactive targets must be at least 24×24 CSS pixels, or have sufficient offset spacing from other targets.
---
Understandable
3.1 Readable
3.1.1 Language of Page (A)
<html lang="en">
<html lang="fr">
<html lang="ar" dir="rtl">3.1.2 Language of Parts (AA) Mark language changes inline:
<p>The French for hello is <span lang="fr">bonjour</span>.</p>---
3.2 Predictable
3.2.1 On Focus (A) No context change on receiving focus alone. No popups, redirects, or form submits triggered by :focus.
3.2.2 On Input (A) No automatic context changes when a user changes input value, unless warned. No auto-submit on select change.
3.2.3 Consistent Navigation (AA) Navigation menus appear in the same order across pages.
3.2.4 Consistent Identification (AA) Components with the same function have the same accessible name across pages. A "Search" button is always labelled "Search", not "Search" on one page and "Find" on another.
3.2.6 Consistent Help (A) (new in 2.2) Help mechanisms (FAQ, chat, contact details) appear in a consistent location across pages.
---
3.3 Input Assistance
3.3.1 Error Identification (A) Errors are identified in text. Not by colour alone. The error message describes what went wrong.
3.3.2 Labels or Instructions (A) Labels or instructions are provided for required format or constraints.
<label for="dob">Date of birth <span aria-hidden="true">(DD/MM/YYYY)</span></label>
<input id="dob" type="text" aria-describedby="dob-format" />
<span id="dob-format" class="visually-hidden">Format: day, month, year. Example: 25/03/1985</span>3.3.3 Error Suggestion (AA) If an error is detected and suggestions for correction are known, provide them.
3.3.4 Error Prevention — Legal, Financial, Data (AA) For submissions with legal/financial consequences: provide a review step, ability to correct, or ability to reverse/cancel.
3.3.7 Redundant Entry (A) (new in 2.2) Information already entered in a multi-step process must be auto-populated or available to select, not required to be re-entered.
3.3.8 Accessible Authentication — Minimum (AA) (new in 2.2) Authentication must not rely solely on a cognitive function test (memorising passwords, solving puzzles) without an alternative. Allowing copy-paste for passwords, password managers, and "show password" toggles all help satisfy this.
---
Robust
4.1 Compatible
4.1.2 Name, Role, Value (A) All UI components must have an accessible name, the correct role, and appropriate state/value programmatically set.
<!-- ✅ Custom checkbox -->
<div role="checkbox"
aria-checked="false"
tabindex="0"
aria-labelledby="label-id">
</div>
<span id="label-id">Accept terms</span>
<!-- ❌ Status changes but not announced -->
<div class="status active">Active</div>
<!-- ✅ State change announced -->
<div aria-live="polite" class="status" aria-label="Status: Active">Active</div>4.1.3 Status Messages (AA) Status messages (success, error, loading) must be programmatically determinable so they can be announced without receiving focus. Use role="status", role="alert", or aria-live.
---
Testing Order by Impact
1. Run automated scan (axe, Lighthouse) — catches ~30–40% of issues 2. Keyboard-only navigation test — Tab through entire page 3. Screen reader test with NVDA + Firefox (or Chrome) 4. Colour contrast audit 5. Zoom/reflow test at 400% 6. Mobile screen reader test with VoiceOver/TalkBack
Automated tools to use:
- axe DevTools (browser extension) — most accurate automated scanner
- Lighthouse (built into Chrome DevTools) — good for quick audits
- WAVE (browser extension) — good for visual overlay of issues
- IBM Equal Access Checker — good WCAG 2.2 coverage