1. Essential HTML Accessibility Fundamentals
1.1 Semantic HTML5 Elements and Structure
| Element | Purpose | Accessibility Impact | Use Case |
|---|---|---|---|
| <header> | Introductory content container | Screen readers identify as banner landmark when top-level | Site/page header with logo, navigation |
| <nav> | Navigation links container | Creates navigation landmark for quick access | Main menu, breadcrumbs, table of contents |
| <main> | Primary page content | Defines main landmark, skip to content target | Core article, application interface (one per page) |
| <article> | Self-contained composition | Independent content region for screen readers | Blog post, news story, widget, forum post |
| <section> | Thematic content grouping | Creates region with accessible name (use with heading) | Chapters, tabbed panels, themed content blocks |
| <aside> | Tangentially related content | Complementary landmark for sidebars | Sidebar, pull quotes, related links, ads |
| <footer> | Footer information | Contentinfo landmark when page-level | Copyright, contact, sitemap links |
| <figure> / <figcaption> | Self-contained illustration | Associates caption with image for context | Images, diagrams, code snippets with captions |
Example: Semantic page structure with landmarks
<header>
<nav aria-label="Main navigation">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
</header>
<main id="main-content">
<article>
<h1>Article Title</h1>
<p>Content...</p>
</article>
<aside aria-label="Related articles">
<h2>Related Content</h2>
</aside>
</main>
<footer>
<p>© 2025 Company</p>
</footer>
Note: Use one <main> per page and ensure semantic elements
are not nested incorrectly (e.g., no <main> inside <aside>).
1.2 Landmark Roles and ARIA Regions
| ARIA Role | HTML5 Equivalent | Purpose | Labeling Requirement |
|---|---|---|---|
| role="banner" | <header> (page-level) | Site-wide header with branding | Optional: aria-label if multiple banners |
| role="navigation" | <nav> | Collection of navigation links | Required: aria-label when multiple navs |
| role="main" | <main> | Primary content of page | None (only one per page) |
| role="complementary" | <aside> | Supporting content | Recommended: aria-label for clarity |
| role="contentinfo" | <footer> (page-level) | Page footer information | None (implied semantics) |
| role="region" | <section> with name | Generic landmark for important content | Required: aria-labelledby or aria-label |
| role="search" | None (use role) | Search functionality container | Optional: aria-label "Site search" |
| role="form" | <form> with name | Form landmark (when named) | Required: aria-label or aria-labelledby |
Example: Multiple navigation landmarks with labels
<nav aria-label="Primary navigation">
<!-- Main menu -->
</nav>
<nav aria-label="Breadcrumb" aria-describedby="breadcrumb-desc">
<span id="breadcrumb-desc" hidden>Current page location</span>
<ol>
<li><a href="/">Home</a></li>
<li>Current Page</li>
</ol>
</nav>
<div role="search">
<form role="search" aria-label="Site search">
<input type="search" aria-label="Search query">
<button type="submit">Search</button>
</form>
</div>
Warning: Don't overuse landmarks - too many can be overwhelming. Prefer
HTML5 semantic elements over explicit ARIA roles when available.
1.3 Heading Hierarchy Best Practices
| Rule | Requirement | Accessibility Impact | Example |
|---|---|---|---|
| Start with H1 | One H1 per page describing main topic | Screen reader users navigate by headings; H1 identifies page purpose | <h1>Product Name</h1> |
| Sequential Order | Don't skip levels (H1→H2→H3, not H1→H3) | Preserves document outline, prevents confusion | H1 > H2 > H3 > H2 > H3 |
| Visual vs Semantic | Use CSS for sizing, not wrong heading level | Maintain logical structure independent of appearance | <h2 class="small"> not <h4> |
| Section Headings | Every <section> should have a heading | Provides accessible name for region | <section><h2>...</h2></section> |
| Landmark Headings | Landmarks benefit from descriptive headings | Helps identify region content quickly | <nav><h2>Main Menu</h2></nav> |
| Hidden Headings | Use visually-hidden class for structure |
Provides screen reader navigation without visual clutter | <h2 class="sr-only">Filters</h2> |
Example: Proper heading hierarchy
<main>
<h1>Dashboard</h1>
<section>
<h2>Recent Activity</h2>
<article>
<h3>Task Completed</h3>
<p>Details...</p>
</article>
</section>
<section>
<h2>Statistics</h2>
<div>
<h3>This Month</h3>
<!-- Stats -->
</div>
</section>
</main>
1.4 Document Language and Direction
| Attribute | Scope | Purpose | Example Value |
|---|---|---|---|
| lang | Document/Element | Specifies language for pronunciation and hyphenation | lang="en", lang="es" |
| lang (regional) | Element-level | Region-specific language variant | lang="en-US", lang="pt-BR" |
| dir | Document/Element | Text direction for RTL languages | dir="ltr", dir="rtl" |
| dir="auto" | Element-level | Automatic direction based on content | dir="auto" for user input |
| translate | Element | Indicates if content should be translated | translate="no" for brand names |
Example: Language and direction declarations
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Multilingual Page</title>
</head>
<body>
<h1>Welcome</h1>
<!-- Spanish section -->
<section lang="es">
<h2>Bienvenido</h2>
<p>Contenido en español...</p>
</section>
<!-- Arabic RTL section -->
<section lang="ar" dir="rtl">
<h2>مرحبا</h2>
<p>محتوى باللغة العربية...</p>
</section>
<!-- Brand name not translated -->
<p>Our product <span lang="en" translate="no">AccessibleUI</span></p>
</body>
</html>
| Common Language Codes | Code | Regional Variants |
|---|---|---|
| English | en |
en-US, en-GB, en-CA, en-AU |
| Spanish | es |
es-ES, es-MX, es-AR |
| French | fr |
fr-FR, fr-CA, fr-BE |
| German | de |
de-DE, de-AT, de-CH |
| Arabic (RTL) | ar |
ar-SA, ar-EG, ar-AE |
| Hebrew (RTL) | he |
he-IL |
| Chinese | zh |
zh-CN (Simplified), zh-TW (Traditional) |
| Japanese | ja |
ja-JP |
Note: Always set lang on <html> element. Screen readers
use this to select proper voice and pronunciation rules.
1.5 Page Titles and Meta Information
| Element/Meta | Purpose | Accessibility Impact | Best Practice |
|---|---|---|---|
| <title> | Browser tab and bookmark text | First thing announced by screen readers; helps identify page | Unique, descriptive, front-load important info |
| viewport meta | Responsive scaling control | Enables pinch-zoom; critical for low vision users | user-scalable=yes, no maximum-scale |
| charset meta | Character encoding | Ensures proper text rendering across languages | <meta charset="UTF-8"> always |
| description meta | Search result snippet | Helps users understand page content before visiting | Concise, accurate, 150-160 characters |
| theme-color meta | Browser UI color | Visual consistency; supports high contrast needs | Match brand, ensure sufficient contrast |
Example: Accessible HTML head structure
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Descriptive, unique title (50-60 chars ideal) -->
<title>Product Features - AccessibleUI Design System</title>
<!-- Clear page description -->
<meta name="description" content="Explore accessible UI components
with WCAG 2.2 AA compliance, including buttons, forms, and navigation.">
<!-- Theme color for browser UI -->
<meta name="theme-color" content="#007acc">
<!-- Skip to main content link -->
<link rel="stylesheet" href="styles.css">
</head>
| Title Pattern | Context | Example |
|---|---|---|
| Page - Site | General pages | <title>About Us - Company Name</title> |
| Item - Category - Site | Detail pages | <title>Blue Widget - Products - Store</title> |
| Status - Action - Site | Form/app states | <title>Error - Checkout - Store</title> |
| Dynamic Update | SPAs, notifications | <title>(3) Messages - Inbox</title> |
Example: Accessible viewport configuration
<!-- ✅ CORRECT: Allows user zoom -->
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- ❌ WRONG: Disables zoom (WCAG failure) -->
<meta name="viewport" content="width=device-width, initial-scale=1.0,
maximum-scale=1.0, user-scalable=no">
Warning: Never use
user-scalable=no or maximum-scale=1.0 - this
violates WCAG 2.2 Success Criterion 1.4.4 (Resize Text) and prevents users from
zooming.
Section 1 Key Takeaways
- Use semantic HTML5 elements (header, nav, main, article, aside, footer) for automatic landmark roles
- Ensure proper heading hierarchy (H1→H2→H3) without skipping levels
- Always declare lang attribute on <html> element for screen reader pronunciation
- Create unique, descriptive page titles that identify content and context
- Never disable zoom/scaling in viewport meta tag
- Label multiple landmarks of same type with aria-label for differentiation
2. ARIA Implementation Guide
2.1 Core ARIA Roles Reference
| Role Category | Role Name | Purpose | Required Attributes |
|---|---|---|---|
| Widget Roles | button |
Clickable element triggering action | Accessible name (via label/text) |
| Widget Roles | checkbox |
Checkable input with 3 states | aria-checked (true/false/mixed) |
| Widget Roles | radio |
Single-select option in group | aria-checked, group context |
| Widget Roles | tab |
Tab in tab list for panels | aria-selected, aria-controls |
| Widget Roles | tablist |
Container for tabs | Contains tab roles |
| Widget Roles | tabpanel |
Content area controlled by tab | aria-labelledby (tab id) |
| Widget Roles | slider |
Range input control | aria-valuenow, aria-valuemin, aria-valuemax |
| Widget Roles | menuitem |
Option in menu | Parent menu or menubar |
| Widget Roles | menuitemcheckbox |
Checkable menu option | aria-checked |
| Widget Roles | menuitemradio |
Radio option in menu | aria-checked |
| Composite Roles | combobox |
Input with popup list | aria-expanded, aria-controls |
| Composite Roles | listbox |
List of selectable options | Contains option roles |
| Composite Roles | grid |
2D data table with interaction | row and gridcell children |
| Composite Roles | tree |
Hierarchical list with expand/collapse | treeitem children |
| Document Roles | article |
Independent content composition | Accessible name (recommended) |
| Document Roles | dialog |
Modal or non-modal dialog window | aria-labelledby or aria-label |
| Document Roles | alertdialog |
Dialog containing alert message | aria-labelledby, aria-describedby |
| Document Roles | tooltip |
Contextual popup information | Accessible name, referenced by aria-describedby |
| Live Region Roles | alert |
Important time-sensitive message | None (implicit aria-live="assertive") |
| Live Region Roles | status |
Advisory status message | None (implicit aria-live="polite") |
| Live Region Roles | log |
Sequential information log | None (live region behavior) |
| Live Region Roles | timer |
Numerical counter or timer | None (updates announced) |
Example: Common ARIA role implementations
<!-- Custom button -->
<div role="button" tabindex="0"
onclick="handleClick()" onkeydown="handleKeydown(event)">
Click Me
</div>
<!-- Checkbox -->
<div role="checkbox" aria-checked="false" tabindex="0">
Accept terms
</div>
<!-- Tab interface -->
<div role="tablist" aria-label="Account settings">
<button role="tab" aria-selected="true" aria-controls="panel1" id="tab1">
Profile
</button>
<button role="tab" aria-selected="false" aria-controls="panel2" id="tab2">
Security
</button>
</div>
<div role="tabpanel" id="panel1" aria-labelledby="tab1">
Profile content...
</div>
Warning: Use native HTML elements when available (
<button>,
<input>) instead of ARIA roles. First Rule of ARIA: Don't use
ARIA if you can use semantic HTML.
2.2 ARIA States and Properties Quick Guide
| Property/State | Values | Purpose | Common Usage |
|---|---|---|---|
| aria-label | String | Provides accessible name when none visible | aria-label="Close dialog" |
| aria-labelledby | ID reference(s) | References element(s) providing name | aria-labelledby="heading1" |
| aria-describedby | ID reference(s) | References element(s) providing description | aria-describedby="hint1" |
| aria-hidden | true/false | Hides element from accessibility tree | aria-hidden="true" for decorative icons |
| aria-expanded | true/false/undefined | Indicates if element is expanded | Accordion, dropdown, expandable sections |
| aria-checked | true/false/mixed | State of checkbox or radio | aria-checked="true" |
| aria-selected | true/false/undefined | Selection state in multi-selectable | Tabs, listbox options, tree items |
| aria-pressed | true/false/mixed | Toggle button pressed state | aria-pressed="true" for active state |
| aria-disabled | true/false | Indicates element is disabled | aria-disabled="true" (still focusable) |
| aria-required | true/false | Indicates required form field | aria-required="true" |
| aria-invalid | true/false/grammar/spelling | Validation error state | aria-invalid="true" with error message |
| aria-live | off/polite/assertive | Live region update priority | aria-live="polite" for status updates |
| aria-atomic | true/false | Announce entire region on change | aria-atomic="true" for timers |
| aria-relevant | additions/removals/text/all | Which changes to announce | aria-relevant="additions text" |
| aria-busy | true/false | Region currently updating | aria-busy="true" during load |
| aria-controls | ID reference(s) | Elements controlled by this element | aria-controls="panel1" |
| aria-owns | ID reference(s) | Defines parent-child relationship | When DOM structure doesn't reflect relationship |
| aria-current | page/step/location/date/time/true/false | Current item in set | aria-current="page" for current nav link |
| aria-haspopup | true/false/menu/listbox/tree/grid/dialog | Indicates popup type | aria-haspopup="menu" |
| aria-modal | true/false | Indicates modal dialog | aria-modal="true" for dialogs |
Example: ARIA states and properties in action
<!-- Expandable section -->
<button aria-expanded="false" aria-controls="details1">
Show details
</button>
<div id="details1" hidden>
Detailed content...
</div>
<!-- Form input with error -->
<input type="email"
id="email"
aria-required="true"
aria-invalid="true"
aria-describedby="email-error">
<span id="email-error" role="alert">
Please enter a valid email address
</span>
<!-- Loading state -->
<div aria-live="polite" aria-busy="true">
Loading content...
</div>
2.3 ARIA Live Regions and Announcements
| Live Region Type | aria-live Value | Priority | Use Case |
|---|---|---|---|
| role="alert" | assertive (implicit) | Immediate interruption | Errors, critical warnings, time-sensitive alerts |
| role="status" | polite (implicit) | Wait for pause in speech | Success messages, advisory info, progress updates |
| aria-live="polite" | polite | Non-interrupting announcement | Status updates, search results count, form hints |
| aria-live="assertive" | assertive | Interrupts current speech | Critical errors, timer expiration, system alerts |
| aria-live="off" | off | No announcements | Disabled live region (default) |
| role="log" | polite (implicit) | Sequential updates | Chat messages, activity feed, console output |
| role="timer" | off (implicit) | Periodic updates (use sparingly) | Countdown, stopwatch, live clock |
| Live Region Attribute | Values | Purpose |
|---|---|---|
| aria-atomic | true/false (default: false) | When true, announces entire region; when false, only changed nodes |
| aria-relevant | additions, removals, text, all | Which types of changes trigger announcements |
| aria-busy | true/false | Suppresses announcements during bulk updates |
Example: Live region implementations
<!-- Alert for errors -->
<div role="alert" aria-live="assertive">
Your session will expire in 1 minute
</div>
<!-- Status updates -->
<div role="status" aria-live="polite" aria-atomic="true">
<span id="search-count">24 results found</span>
</div>
<!-- Chat log -->
<div role="log" aria-live="polite" aria-relevant="additions">
<div>User1: Hello</div>
<div>User2: Hi there</div>
</div>
<!-- Loading with aria-busy -->
<div aria-live="polite" aria-busy="true">
<!-- Content being updated -->
</div>
<script>
// After update completes
element.setAttribute('aria-busy', 'false');
</script>
Note: Live regions must exist in DOM before content changes
occur. Create empty live region on page load, then update its content to trigger announcements.
Warning: Overuse of
aria-live="assertive" creates jarring experience. Reserve for
truly critical alerts only. Use polite for most cases.
2.4 ARIA Labeling Techniques
| Technique | When to Use | Priority | Example |
|---|---|---|---|
| Native Label | Form inputs with visible labels | 1st choice (best support) | <label for="name">Name:</label> |
| aria-labelledby | Multiple elements form the label | 2nd choice (flexible) | aria-labelledby="id1 id2 id3" |
| aria-label | No visible label text available | 3rd choice (translation issues) | aria-label="Close dialog" |
| aria-describedby | Additional context/instructions | Supplement to label | aria-describedby="hint1" |
| title attribute | Last resort (limited AT support) | Avoid if possible | title="Tooltip text" |
| Accessible Name Calculation | Understanding precedence | Knowledge essential | aria-labelledby > aria-label > native label > content |
Example: Various labeling techniques
<!-- Native label (preferred) -->
<label for="username">Username:</label>
<input type="text" id="username" name="username">
<!-- aria-labelledby (composite label) -->
<div id="billing-heading">Billing Address</div>
<div id="billing-hint">(must match credit card)</div>
<input type="text"
aria-labelledby="billing-heading billing-hint"
placeholder="123 Main St">
<!-- aria-label (icon button) -->
<button aria-label="Close dialog">
<svg aria-hidden="true"><!-- X icon --></svg>
</button>
<!-- aria-describedby (extra context) -->
<label for="password">Password:</label>
<input type="password"
id="password"
aria-describedby="password-requirements">
<div id="password-requirements">
Must be at least 8 characters with 1 number
</div>
<!-- Complex dialog labeling -->
<div role="dialog"
aria-labelledby="dialog-title"
aria-describedby="dialog-desc">
<h2 id="dialog-title">Confirm Deletion</h2>
<p id="dialog-desc">This action cannot be undone</p>
<!-- Dialog content -->
</div>
Label Precedence Order
aria-labelledby(highest)aria-label<label>elementplaceholder(input only)- Element content text
titleattribute (lowest)
Common Mistakes:
- Using
aria-labelon<div>without role - Empty
aria-label=""(usearia-hidden) - Conflicting labels (both
aria-labelandlabelledby) - Referencing non-existent IDs
2.5 ARIA Best Practices and Patterns
| Best Practice | Guideline | Rationale |
|---|---|---|
| First Rule of ARIA | Use native HTML elements when possible | Native elements have built-in semantics, keyboard support, and better compatibility |
| Don't Change Native Semantics | Don't use role to override native HTML |
<button role="heading"> breaks expected behavior |
| Interactive ARIA Requires Keyboard | All interactive roles need keyboard handling | Add tabindex, Enter/Space handlers for custom controls |
| Don't Hide Focusable Elements | Never aria-hidden="true" on focusable elements |
Creates confusing experience - focus on invisible element |
| All Interactive Elements Are Focusable | Interactive elements need tabindex="0" or native focus |
Keyboard users must be able to reach and activate controls |
| Manage Focus Appropriately | Move focus to opened dialogs, restore on close | Keeps keyboard users oriented in document |
| Avoid Redundant ARIA | Don't add role="button" to <button> |
Native elements already have implicit roles |
| Update States Dynamically | Toggle aria-expanded, aria-checked with JS |
Screen readers announce state changes when attributes update |
| Provide Accessible Names | Every interactive element needs accessible name | Users must understand purpose of controls |
| Test With Screen Readers | Automated tools catch ~30% of issues | Real AT testing reveals actual user experience |
Example: Good vs bad ARIA usage
<!-- ❌ BAD: Overriding native semantics -->
<button role="heading">Click me</button>
<!-- ✅ GOOD: Use semantic HTML -->
<h2><button>Click me</button></h2>
<!-- ❌ BAD: aria-hidden on focusable element -->
<button aria-hidden="true" tabindex="0">Submit</button>
<!-- ✅ GOOD: Hide decorative, not interactive -->
<button>
<svg aria-hidden="true"><!-- icon --></svg>
Submit
</button>
<!-- ❌ BAD: Role without keyboard support -->
<div role="button" onclick="submit()">Submit</div>
<!-- ✅ GOOD: Proper keyboard handling -->
<div role="button"
tabindex="0"
onclick="submit()"
onkeydown="if(event.key==='Enter'||event.key===' ')submit()">
Submit
</div>
<!-- ✅ BEST: Use native button -->
<button onclick="submit()">Submit</button>
Warning: ARIA only changes semantics, not behavior. Adding
role="button" doesn't make an element clickable or keyboard accessible - you must implement those
features yourself.
2.6 ARIA Landmark Implementation
| Landmark Role | HTML Equivalent | Usage Guidelines | Labeling |
|---|---|---|---|
| role="banner" | <header> (page-level only) | Site-wide header at top of page (one per page) | Usually unlabeled; if multiple, use aria-label |
| role="navigation" | <nav> | Site/page navigation links (2-3 max recommended) | Required when multiple: aria-label |
| role="main" | <main> | Primary page content (exactly one per page) | Not needed (only one allowed) |
| role="complementary" | <aside> | Supporting content related to main content | Recommended: aria-label for clarity |
| role="contentinfo" | <footer> (page-level only) | Site footer with metadata (one per page) | Usually unlabeled |
| role="search" | None (must use role) | Search functionality container | Optional: aria-label="Site search" |
| role="region" | <section> with accessible name | Important content section (use sparingly) | Required: aria-labelledby or aria-label |
| role="form" | <form> with accessible name | Form becomes landmark only when labeled | Required for landmark: aria-label |
Example: Complete landmark structure
<!DOCTYPE html>
<html lang="en">
<body>
<!-- Banner landmark -->
<header>
<h1>Site Name</h1>
<!-- Navigation landmark -->
<nav aria-label="Primary navigation">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
<!-- Search landmark -->
<div role="search">
<form role="search" aria-label="Site search">
<input type="search" aria-label="Search query">
<button type="submit">Search</button>
</form>
</div>
</header>
<!-- Breadcrumb navigation -->
<nav aria-label="Breadcrumb">
<ol>
<li><a href="/">Home</a></li>
<li>Current Page</li>
</ol>
</nav>
<!-- Main landmark -->
<main id="main-content">
<h1>Page Title</h1>
<!-- Important region -->
<section aria-labelledby="updates-heading">
<h2 id="updates-heading">Latest Updates</h2>
<!-- Content -->
</section>
<!-- Form landmark -->
<form aria-label="Contact form">
<!-- Form fields -->
</form>
</main>
<!-- Complementary landmark -->
<aside aria-label="Related articles">
<h2>You might also like</h2>
<!-- Sidebar content -->
</aside>
<!-- Contentinfo landmark -->
<footer>
<p>© 2025 Company Name</p>
<nav aria-label="Footer navigation">
<!-- Footer links -->
</nav>
</footer>
</body>
</html>
Note: Screen reader users can navigate directly to landmarks with keyboard shortcuts (e.g.,
D for landmark list in NVDA). Proper landmark structure is critical for efficient
navigation.
Section 2 Key Takeaways
- First Rule of ARIA: Use native HTML elements instead of ARIA roles whenever possible
- ARIA only changes semantics, not behavior - you must add keyboard support manually
- Create live regions before updating their content to ensure announcements work
- Use
aria-live="polite"for most updates; reserve"assertive"for critical alerts only - Label all interactive elements with
aria-label,aria-labelledby, or native labels - Never use
aria-hidden="true"on focusable elements - Limit landmarks to 2-3 of each type maximum to avoid overwhelming users
- Test with actual screen readers - automated tools only catch ~30% of accessibility issues
3. Keyboard Navigation and Focus Management
3.1 Tab Order and Tabindex Usage
| tabindex Value | Behavior | Use Case | Support |
|---|---|---|---|
| tabindex="0" | Element enters natural tab order | Make custom controls focusable (buttons, widgets) | All browsers |
| tabindex="-1" | Focusable via JS only, removed from tab order | Programmatic focus (skip links, dialogs) | All browsers |
| tabindex="1+" AVOID | Sets explicit tab order (anti-pattern) | None - breaks natural flow, hard to maintain | All browsers |
| No tabindex | Native focusable elements (buttons, links, inputs) | Default for interactive HTML elements | All browsers |
Example: Making a custom button focusable
<!-- Good: Custom widget with tabindex="0" -->
<div role="button" tabindex="0" onclick="handleClick()">
Click Me
</div>
<!-- Good: Programmatic focus target -->
<div id="main-content" tabindex="-1">
Main content starts here
</div>
<!-- Bad: Positive tabindex disrupts natural order -->
<button tabindex="3">Third</button>
<button tabindex="1">First</button>
<button tabindex="2">Second</button>
Warning: Positive tabindex values (1+) override natural document flow and become a maintenance
nightmare. Never use them unless absolutely necessary for legacy code.
| Natural Tab Order | Elements Included | Notes |
|---|---|---|
| Interactive Elements | <a>, <button>, <input>,
<select>, <textarea>
|
Automatically focusable in document order |
| Editable Content | contenteditable="true" |
Becomes focusable and editable |
| Media Controls | <video controls>, <audio controls> |
Built-in controls are keyboard accessible |
| Summary/Details | <summary> inside <details> |
Toggle button is automatically focusable |
3.2 Focus Indicators and Styling
| CSS Pseudo-class | Triggers | Use Case | Browser Support |
|---|---|---|---|
| :focus | Any focus (keyboard or mouse) | Basic focus styling (legacy) | All browsers |
| :focus-visible NEW | Keyboard focus only | Show indicators only for keyboard users | Modern browsers |
| :focus-within | Element or descendant has focus | Style parent when child is focused | Modern browsers |
| :has(:focus) | Contains focused element | Advanced parent styling (modern) | Chrome 105+, Safari 15.4+ |
Example: Modern focus indicator patterns
/* Basic focus indicator - always visible */
button:focus {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
/* Keyboard-only focus indicator (recommended) */
button:focus-visible {
outline: 3px solid #0066cc;
outline-offset: 3px;
box-shadow: 0 0 0 4px rgba(0, 102, 204, 0.2);
}
/* Remove mouse focus indicator */
button:focus:not(:focus-visible) {
outline: none;
}
/* Style parent form when any input is focused */
.form-group:focus-within {
background: #f0f8ff;
border-color: #0066cc;
}
/* High contrast mode support */
@media (prefers-contrast: high) {
button:focus-visible {
outline: 4px solid currentColor;
outline-offset: 4px;
}
}
| Focus Indicator Best Practice | Minimum Requirement | Recommended |
|---|---|---|
| Contrast Ratio | 3:1 against background (WCAG 2.2) | 4.5:1 for better visibility |
| Thickness | 2px minimum | 3px for clearer visibility |
| Offset | 0-2px from element | 2-4px for better separation |
| Shape | Follows element shape or rectangle | Add rounded corners for modern look |
| Animation | Respect prefers-reduced-motion |
Subtle fade-in (150-200ms max) |
Note: Never use
outline: none without providing an alternative focus indicator.
This breaks keyboard navigation for millions of users.
3.3 Skip Links Implementation
| Skip Link Type | Target | Usage | Position |
|---|---|---|---|
| Skip to Main | #main-content |
Bypass navigation/header | First focusable element on page |
| Skip to Navigation | #primary-nav |
Quick access to menu | Second skip link (optional) |
| Skip to Footer | #footer |
Jump to contact/legal info | Third skip link (rare) |
| Skip Repeating Content | Section-specific | Bypass sidebars, ads, related content | Before repeated blocks |
Example: Complete skip link implementation
<!-- HTML Structure -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<header>...navigation...</header>
<main id="main-content" tabindex="-1">
<h1>Page Title</h1>
...
</main>
<!-- CSS: Show on focus -->
<style>
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #0066cc;
color: #fff;
padding: 8px 16px;
text-decoration: none;
font-weight: 600;
z-index: 9999;
transition: top 0.2s;
}
.skip-link:focus {
top: 0;
outline: 3px solid #fff;
outline-offset: 2px;
}
</style>
<!-- JavaScript: Ensure focus works in all browsers -->
<script>
document.querySelectorAll('a[href^="#"]').forEach(link => {
link.addEventListener('click', e => {
const target = document.querySelector(link.getAttribute('href'));
if (target) {
target.focus();
// Fallback for elements without tabindex
if (document.activeElement !== target) {
target.setAttribute('tabindex', '-1');
target.focus();
}
}
});
});
</script>
Warning: Skip links must be the first focusable element on the
page. Don't hide them with
display: none or visibility: hidden - use off-screen
positioning instead.
3.4 Keyboard Event Handling
| Key | Event Code | Common Usage | Widget Pattern |
|---|---|---|---|
| Enter | event.key === 'Enter' |
Activate buttons, submit forms | Buttons, links, dialogs |
| Space | event.key === ' ' |
Activate buttons, toggle checkboxes | Buttons, checkboxes, switches |
| Escape | event.key === 'Escape' |
Close dialogs, cancel operations | Modals, dropdowns, tooltips |
| Arrow Keys | ArrowUp/Down/Left/Right |
Navigate lists, adjust values | Menus, tabs, sliders, grids |
| Tab | event.key === 'Tab' |
Move focus forward | General navigation |
| Shift+Tab | event.shiftKey && event.key === 'Tab' |
Move focus backward | General navigation |
| Home | event.key === 'Home' |
Jump to first item | Lists, grids, sliders |
| End | event.key === 'End' |
Jump to last item | Lists, grids, sliders |
| Page Up/Down | PageUp/PageDown |
Scroll by page, jump items | Long lists, calendars |
Example: Comprehensive keyboard handler for custom button
// Custom button with keyboard support
const customButton = document.querySelector('[role="button"]');
customButton.addEventListener('keydown', (e) => {
// Activate on Enter or Space
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); // Prevent page scroll on Space
customButton.click();
}
});
// Prevent default space scroll behavior
customButton.addEventListener('keyup', (e) => {
if (e.key === ' ') {
e.preventDefault();
}
});
// Arrow key navigation for menu
const menuItems = document.querySelectorAll('[role="menuitem"]');
let currentIndex = 0;
document.addEventListener('keydown', (e) => {
if (!menuItems.length) return;
switch(e.key) {
case 'ArrowDown':
e.preventDefault();
currentIndex = (currentIndex + 1) % menuItems.length;
menuItems[currentIndex].focus();
break;
case 'ArrowUp':
e.preventDefault();
currentIndex = (currentIndex - 1 + menuItems.length) % menuItems.length;
menuItems[currentIndex].focus();
break;
case 'Home':
e.preventDefault();
currentIndex = 0;
menuItems[0].focus();
break;
case 'End':
e.preventDefault();
currentIndex = menuItems.length - 1;
menuItems[currentIndex].focus();
break;
case 'Escape':
closeMenu();
break;
}
});
| Widget Pattern | Required Keys | Optional Keys | ARIA Pattern Link |
|---|---|---|---|
| Button | Enter, Space | - | Native behavior preferred |
| Tabs | Left/Right Arrow, Home, End | Delete (closable tabs) | Automatic activation recommended |
| Menu | Up/Down Arrow, Escape, Enter | Home, End, type-ahead | First item focused on open |
| Dialog | Escape (close) | - | Focus trap required |
| Slider | Left/Right or Up/Down Arrow | Home, End, Page Up/Down | Arrow keys change value |
| Grid | All arrow keys, Home, End | Ctrl+Home, Ctrl+End, Page Up/Down | Cell-level or row-level focus |
Note: Always use
event.key instead of deprecated event.keyCode. Use
event.preventDefault() to prevent default browser behavior when implementing custom key handling.
3.5 Focus Trap Patterns
| Focus Trap Type | Use Case | Implementation | Exit Method |
|---|---|---|---|
| Modal Dialog | Blocking user action required | Trap focus within dialog boundary | Escape key or explicit close button |
| Navigation Menu | Mega menu or sidebar | Trap while menu is open | Escape, outside click, or navigate |
| Inline Edit Mode | Table cell or content editing | Trap in edit controls | Save/Cancel or Enter/Escape |
| Multi-step Wizard | Form completion flow | Trap within current step | Next/Previous or Cancel button |
Example: Complete focus trap implementation for modal
class FocusTrap {
constructor(element) {
this.element = element;
this.focusableSelectors = [
'a[href]',
'button:not([disabled])',
'textarea:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(', ');
this.previousFocus = null;
}
activate() {
// Store currently focused element
this.previousFocus = document.activeElement;
// Get all focusable elements
const focusable = Array.from(
this.element.querySelectorAll(this.focusableSelectors)
);
if (!focusable.length) return;
this.firstFocusable = focusable[0];
this.lastFocusable = focusable[focusable.length - 1];
// Focus first element
this.firstFocusable.focus();
// Add event listeners
this.element.addEventListener('keydown', this.handleKeydown);
}
handleKeydown = (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
// Shift+Tab: wrap to last element
if (document.activeElement === this.firstFocusable) {
e.preventDefault();
this.lastFocusable.focus();
}
} else {
// Tab: wrap to first element
if (document.activeElement === this.lastFocusable) {
e.preventDefault();
this.firstFocusable.focus();
}
}
}
deactivate() {
this.element.removeEventListener('keydown', this.handleKeydown);
// Restore focus to previous element
if (this.previousFocus && this.previousFocus.focus) {
this.previousFocus.focus();
}
}
}
// Usage
const modal = document.querySelector('[role="dialog"]');
const trap = new FocusTrap(modal);
// When opening modal
function openModal() {
modal.style.display = 'block';
modal.setAttribute('aria-hidden', 'false');
trap.activate();
}
// When closing modal
function closeModal() {
trap.deactivate();
modal.style.display = 'none';
modal.setAttribute('aria-hidden', 'true');
}
Warning: Focus traps must always include an accessible way to
exit (Escape key minimum). Trapping users without an exit is a WCAG violation and creates a terrible UX.
| Focus Trap Best Practice | Requirement | Reason |
|---|---|---|
| Store Previous Focus | Save document.activeElement before trap |
Restore focus when trap deactivates |
| Focus First Element | Move focus to first focusable on open | Clear starting point for keyboard users |
| Handle Tab Wrap | Tab from last → first, Shift+Tab from first → last | Prevent focus escaping trap boundary |
| Update Dynamically | Recalculate focusable elements if content changes | Ensure trap includes new/removed elements |
| Escape Key Exit | Always listen for Escape to close | Standard pattern users expect |
| Inert Background | Use inert attribute on non-trap elements |
Prevent interaction with background content |
3.6 Custom Focus Management APIs
| API/Method | Purpose | Syntax | Browser Support |
|---|---|---|---|
| element.focus() | Move focus to element | element.focus({ preventScroll: true }) |
All browsers |
| element.blur() | Remove focus from element | element.blur() |
All browsers |
| document.activeElement | Get currently focused element | const focused = document.activeElement |
All browsers |
| element.matches(':focus-within') | Check if element or descendant has focus | if (parent.matches(':focus-within')) {...} |
Modern browsers |
| inert attribute NEW | Remove element and descendants from tab order | <div inert>...</div> |
Chrome 102+, Firefox 112+ |
| focusin/focusout events | Listen for focus changes (bubbling) | element.addEventListener('focusin', fn) |
All browsers |
| focus/blur events | Listen for focus changes (non-bubbling) | element.addEventListener('focus', fn, true) |
All browsers |
Example: Advanced focus management patterns
// Focus management utility class
class FocusManager {
// Move focus with optional scroll prevention
static moveFocusTo(element, preventScroll = false) {
if (!element) return;
element.focus({ preventScroll });
}
// Focus next/previous focusable element
static focusNext() {
const focusable = this.getFocusableElements();
const currentIndex = focusable.indexOf(document.activeElement);
const nextIndex = (currentIndex + 1) % focusable.length;
focusable[nextIndex].focus();
}
static focusPrevious() {
const focusable = this.getFocusableElements();
const currentIndex = focusable.indexOf(document.activeElement);
const prevIndex = (currentIndex - 1 + focusable.length) % focusable.length;
focusable[prevIndex].focus();
}
// Get all focusable elements in document
static getFocusableElements() {
const selector = [
'a[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
'[contenteditable="true"]'
].join(',');
return Array.from(document.querySelectorAll(selector))
.filter(el => {
// Filter out hidden and inert elements
return el.offsetParent !== null && !el.hasAttribute('inert');
});
}
// Check if element is currently focused
static isFocused(element) {
return document.activeElement === element;
}
// Check if element or child has focus
static hasFocusWithin(element) {
return element.matches(':focus-within');
}
// Restore focus after async operation
static async withFocusRestore(callback) {
const previousFocus = document.activeElement;
await callback();
if (previousFocus && previousFocus.focus) {
previousFocus.focus();
}
}
}
// Usage examples
// Focus first heading without scrolling
const heading = document.querySelector('h1');
FocusManager.moveFocusTo(heading, true);
// Navigate to next focusable element
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'ArrowDown') {
e.preventDefault();
FocusManager.focusNext();
}
});
// Make background inert when modal opens
function openModal(modal) {
document.body.setAttribute('inert', '');
modal.removeAttribute('inert');
modal.querySelector('button').focus();
}
// Track focus changes
document.addEventListener('focusin', (e) => {
console.log('Focus moved to:', e.target);
// Announce to screen reader if needed
announceToScreenReader(`Focused on ${e.target.getAttribute('aria-label')}`);
});
| SPA Focus Pattern | Scenario | Implementation |
|---|---|---|
| Route Change | User navigates to new page | Focus page heading or skip link target |
| Content Update | Async data loads in current view | Move focus to new content heading or first interactive element |
| Error State | Form validation fails | Focus first invalid field and announce error |
| Success Notification | Action completes successfully | Focus notification or next logical element |
| Loading State | Content is loading | Keep focus on trigger element or loading indicator |
| Modal Close | Dialog dismissed | Return focus to element that opened modal |
Note: The
inert attribute is a powerful tool for managing focus in complex UIs. It
removes entire subtrees from tab order and screen reader navigation, making it ideal for modal backgrounds and
hidden content.
Keyboard Navigation Quick Reference
- Use
tabindex="0"for custom interactive elements,"-1"for programmatic focus only - Never remove focus indicators without providing an alternative - use
:focus-visibleinstead - Skip links must be the first focusable element and visible on focus
- Implement keyboard handlers for all custom widgets following ARIA authoring practices
- Focus traps must have an escape mechanism (minimum: Escape key)
- Restore focus to previous element when dismissing modals or completing flows
- Use
inertattribute to disable entire sections during modal interactions - Test with keyboard only (unplug mouse) to verify all functionality is accessible
4. Form Accessibility Implementation
4.1 Input Labels and Descriptions
| Labeling Method | Syntax | Use Case | Screen Reader Behavior |
|---|---|---|---|
| <label> with for | <label for="id">...</label> |
Standard input labeling (recommended) | Announces label text when field focused |
| <label> wrapping | <label>Text <input></label> |
Implicit association (simpler markup) | Same as explicit label |
| aria-label | <input aria-label="Label text"> |
When visual label isn't needed/possible | Announces aria-label value |
| aria-labelledby | <input aria-labelledby="id1 id2"> |
Multiple labels or non-label elements | Concatenates referenced elements' text |
| aria-describedby | <input aria-describedby="help-id"> |
Additional help text or hints | Announces after label (often delayed) |
| title attribute AVOID | <input title="Label"> |
Legacy fallback only | Inconsistent screen reader support |
| placeholder NOT A LABEL | <input placeholder="Example"> |
Example text only, never use as label | Poor support, disappears on input |
Example: Comprehensive label patterns
<!-- Best: Explicit label with for attribute -->
<label for="email">Email Address</label>
<input type="email" id="email" name="email" required>
<!-- Good: Wrapped label (implicit association) -->
<label>
Phone Number
<input type="tel" name="phone">
</label>
<!-- Good: Label with help text -->
<label for="username">Username</label>
<input
type="text"
id="username"
aria-describedby="username-help">
<span id="username-help" class="help-text">
Must be 3-20 characters, letters and numbers only
</span>
<!-- Multiple descriptors -->
<label for="password">Password</label>
<input
type="password"
id="password"
aria-describedby="pwd-requirements pwd-strength">
<div id="pwd-requirements">Min 8 characters, 1 uppercase, 1 number</div>
<div id="pwd-strength" aria-live="polite">Strength: Weak</div>
<!-- Labelledby for complex labels -->
<span id="price-label">Price</span>
<span id="currency-label">(USD)</span>
<input
type="number"
aria-labelledby="price-label currency-label">
<!-- Search with aria-label (icon button) -->
<input
type="search"
aria-label="Search products"
placeholder="Search...">
<button aria-label="Submit search">🔍</button>
Warning: Never use placeholder as the only label. It disappears
when users type, has poor contrast, and isn't reliably announced by screen readers. Always provide a proper
<label> element.
| Input Type | Required Attributes | Recommended Additions |
|---|---|---|
| Text/Email/Tel/URL | id, name, associated <label> |
autocomplete, inputmode, aria-describedby |
| Password | id, name, <label>,
autocomplete="current-password"
|
Show/hide toggle button, strength indicator |
| Checkbox/Radio | id, name, <label> |
Group with <fieldset> if related |
| Select | id, name, <label> |
Default option or placeholder option with disabled |
| Textarea | id, name, <label> |
maxlength, character counter (live region) |
| Range/Number | id, name, <label>, min, max |
step, aria-valuemin/max/now for custom sliders |
| File | id, name, <label> |
accept, instructions about file types/size |
4.2 Fieldsets and Form Grouping
| Grouping Element | Purpose | Required Children | Use Case |
|---|---|---|---|
| <fieldset> | Groups related form controls | <legend> as first child |
Radio groups, checkbox groups, address sections |
| <legend> | Label for fieldset group | Must be first child of <fieldset> |
Announces group context to screen readers |
| role="group" | ARIA alternative to fieldset | aria-labelledby or aria-label |
When fieldset styling is problematic |
| role="radiogroup" | Semantic radio button group | aria-label or aria-labelledby |
Custom radio implementations |
Example: Fieldset patterns for different form sections
<!-- Radio button group -->
<fieldset>
<legend>Shipping Method</legend>
<label>
<input type="radio" name="shipping" value="standard" checked>
Standard (5-7 days) - Free
</label>
<label>
<input type="radio" name="shipping" value="express">
Express (2-3 days) - $9.99
</label>
<label>
<input type="radio" name="shipping" value="overnight">
Overnight - $24.99
</label>
</fieldset>
<!-- Checkbox group -->
<fieldset>
<legend>
Interests <span class="help-text">(Select all that apply)</span>
</legend>
<label>
<input type="checkbox" name="interests" value="web">
Web Development
</label>
<label>
<input type="checkbox" name="interests" value="mobile">
Mobile Development
</label>
<label>
<input type="checkbox" name="interests" value="design">
UI/UX Design
</label>
</fieldset>
<!-- Complex address group -->
<fieldset>
<legend>Billing Address</legend>
<label for="street">Street Address</label>
<input type="text" id="street" name="street" autocomplete="street-address">
<div class="form-row">
<div>
<label for="city">City</label>
<input type="text" id="city" name="city" autocomplete="address-level2">
</div>
<div>
<label for="state">State</label>
<select id="state" name="state" autocomplete="address-level1">
<option value="">Select state</option>
<option value="CA">California</option>
<option value="NY">New York</option>
</select>
</div>
<div>
<label for="zip">ZIP Code</label>
<input type="text" id="zip" name="zip" autocomplete="postal-code">
</div>
</div>
</fieldset>
<!-- ARIA group alternative (when fieldset styling is problematic) -->
<div role="group" aria-labelledby="payment-heading">
<h3 id="payment-heading">Payment Method</h3>
<label>
<input type="radio" name="payment" value="card">
Credit Card
</label>
<label>
<input type="radio" name="payment" value="paypal">
PayPal
</label>
</div>
| Fieldset Best Practice | Implementation | Reason |
|---|---|---|
| Always Use Legend | Include <legend> as first child |
Provides context for screen reader users |
| Keep Legend Concise | Short, descriptive text (3-8 words) | Read before each field in group |
| Don't Nest Fieldsets | Avoid nested fieldsets when possible | Complex nesting confuses screen readers |
| Style Appropriately | Remove default border if needed, keep semantic HTML | Visual design shouldn't compromise accessibility |
| Use for Related Groups | Group radio buttons, related checkboxes, address parts | Establishes relationship between controls |
4.3 Error Handling and Validation Messages
| Error Pattern | Implementation | ARIA Attributes | Timing |
|---|---|---|---|
| Inline Field Error | Error message below field, linked via aria-describedby |
aria-invalid="true", aria-describedby |
On blur or submit |
| Error Summary | List of errors at top of form | role="alert" or aria-live="assertive" |
On submit |
| Real-time Validation | Update error state as user types | aria-live="polite", aria-invalid |
During input (debounced) |
| Success Feedback | Confirmation message after correction | aria-live="polite", remove aria-invalid |
After valid input |
| Required Field Missing | Specific message about requirement | aria-required="true", aria-invalid="true" |
On submit or blur |
Example: Comprehensive error handling implementation
<!-- Error summary at form top -->
<form id="signup-form" novalidate>
<div id="error-summary" role="alert" aria-live="assertive" hidden>
<h2>Please correct the following errors:</h2>
<ul id="error-list"></ul>
</div>
<!-- Field with inline error -->
<div class="form-group">
<label for="email">Email Address <span aria-label="required">*</span></label>
<input
type="email"
id="email"
name="email"
required
aria-required="true"
aria-invalid="false"
aria-describedby="email-error email-help">
<span id="email-help" class="help-text">We'll never share your email</span>
<span id="email-error" class="error-message" hidden></span>
</div>
<!-- Password with real-time validation -->
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
id="password"
aria-describedby="pwd-requirements pwd-error"
aria-invalid="false">
<div id="pwd-requirements">
<ul>
<li id="pwd-length" aria-invalid="true">At least 8 characters</li>
<li id="pwd-uppercase" aria-invalid="true">One uppercase letter</li>
<li id="pwd-number" aria-invalid="true">One number</li>
</ul>
</div>
<div id="pwd-error" class="error-message" aria-live="polite" hidden></div>
</div>
<button type="submit">Create Account</button>
</form>
<style>
.error-message {
color: #d32f2f;
font-size: 0.875rem;
margin-top: 4px;
display: flex;
align-items: center;
}
.error-message::before {
content: "⚠ ";
margin-right: 4px;
}
[aria-invalid="true"] {
border-color: #d32f2f;
border-width: 2px;
}
[aria-invalid="false"]::before {
content: "✓";
color: #2e7d32;
}
</style>
<script>
const form = document.getElementById('signup-form');
const emailInput = document.getElementById('email');
const emailError = document.getElementById('email-error');
const errorSummary = document.getElementById('error-summary');
const errorList = document.getElementById('error-list');
// Email validation on blur
emailInput.addEventListener('blur', () => {
validateEmail();
});
function validateEmail() {
const email = emailInput.value.trim();
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!email) {
showFieldError(emailInput, emailError, 'Email address is required');
return false;
} else if (!emailRegex.test(email)) {
showFieldError(emailInput, emailError, 'Please enter a valid email address');
return false;
} else {
clearFieldError(emailInput, emailError);
return true;
}
}
function showFieldError(input, errorElement, message) {
input.setAttribute('aria-invalid', 'true');
errorElement.textContent = message;
errorElement.hidden = false;
}
function clearFieldError(input, errorElement) {
input.setAttribute('aria-invalid', 'false');
errorElement.textContent = '';
errorElement.hidden = true;
}
// Form submission
form.addEventListener('submit', (e) => {
e.preventDefault();
const errors = [];
// Validate all fields
if (!validateEmail()) {
errors.push({ field: 'email', message: 'Email address is invalid' });
}
if (errors.length > 0) {
// Show error summary
errorList.innerHTML = errors.map(err =>
`<li><a href="#${err.field}">${err.message}</a></li>`
).join('');
errorSummary.hidden = false;
// Focus first error field
const firstErrorField = document.getElementById(errors[0].field);
firstErrorField.focus();
} else {
// Submit form
errorSummary.hidden = true;
console.log('Form is valid, submitting...');
}
});
</script>
Warning: Always set
aria-invalid="false" initially and update to
"true" only when validation fails. Set back to "false" when user corrects the error.
| Error Message Best Practice | Good Example | Bad Example |
|---|---|---|
| Be Specific | "Email must include @ symbol" | "Invalid input" |
| Provide Solution | "Password must be at least 8 characters. Currently: 5" | "Password too short" |
| Use Plain Language | "Please enter your phone number" | "Field validation failed: ERR_TEL_001" |
| Indicate Field | "Email address is required" | "This field is required" |
| Avoid Jargon | "Enter a date in MM/DD/YYYY format" | "Date regex mismatch" |
4.4 Required Field Indicators
| Method | Implementation | Accessibility | Visual |
|---|---|---|---|
| HTML5 required | <input required> |
Browser validation, auto aria-required |
No visual by default |
| aria-required | <input aria-required="true"> |
Announces "required" to screen readers | No visual by default |
| Asterisk (*) | <abbr title="required">*</abbr> |
Must explain at form start | Common visual indicator |
| Text Label | <span>(required)</span> |
Best for clarity | More space, very clear |
| Hidden Text | <span class="sr-only">required</span> |
Screen reader only announcement | No visual (relies on asterisk or other) |
Example: Required field patterns
<!-- Best: Combine HTML5 required with visible indicator -->
<form>
<p>Fields marked with <abbr title="required">*</abbr> are required.</p>
<!-- Method 1: Asterisk with aria-label -->
<label for="name">
Full Name
<span aria-label="required" class="required">*</span>
</label>
<input type="text" id="name" required>
<!-- Method 2: Text in parentheses -->
<label for="email">
Email Address <span class="required-text">(required)</span>
</label>
<input type="email" id="email" required aria-required="true">
<!-- Method 3: Screen reader text + visual asterisk -->
<label for="phone">
Phone Number
<abbr title="required" aria-label="required">*</abbr>
</label>
<input type="tel" id="phone" required>
<!-- Optional field (clearly marked) -->
<label for="company">
Company Name <span class="optional-text">(optional)</span>
</label>
<input type="text" id="company">
</form>
<style>
.required {
color: #d32f2f;
font-weight: bold;
}
.required-text {
color: #666;
font-size: 0.875rem;
font-weight: normal;
}
.optional-text {
color: #999;
font-size: 0.875rem;
font-weight: normal;
font-style: italic;
}
/* CSS to show asterisk on required inputs without manual markup */
label:has(+ input[required])::after,
label:has(+ select[required])::after,
label:has(+ textarea[required])::after {
content: " *";
color: #d32f2f;
}
</style>
Note: Always explain required field indicators at the start of the form. Don't rely solely on
color or asterisks - provide text equivalents for screen reader users.
4.5 Custom Form Controls
| Custom Control | ARIA Role | Required Attributes | Keyboard Interaction |
|---|---|---|---|
| Custom Checkbox | role="checkbox" |
aria-checked, tabindex="0" |
Space to toggle |
| Custom Radio | role="radio" |
aria-checked, tabindex (roving) |
Arrow keys navigate, Space selects |
| Toggle Switch | role="switch" |
aria-checked, tabindex="0" |
Space to toggle |
| Custom Select | role="combobox" |
aria-expanded, aria-controls, aria-activedescendant |
Arrow keys, Enter, Escape |
| Range Slider | role="slider" |
aria-valuemin, aria-valuemax, aria-valuenow |
Arrow keys adjust value |
| Spin Button | role="spinbutton" |
aria-valuemin, aria-valuemax, aria-valuenow |
Up/Down arrows, Page Up/Down |
Example: Custom checkbox implementation
<!-- HTML -->
<div class="custom-checkbox">
<div
role="checkbox"
aria-checked="false"
aria-labelledby="terms-label"
tabindex="0"
id="terms-checkbox"
class="checkbox-control">
<span class="checkbox-icon" aria-hidden="true"></span>
</div>
<label id="terms-label" for="terms-checkbox">
I agree to the terms and conditions
</label>
</div>
<style>
.custom-checkbox {
display: flex;
align-items: center;
gap: 8px;
}
.checkbox-control {
width: 20px;
height: 20px;
border: 2px solid #666;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.checkbox-control:focus {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
.checkbox-control[aria-checked="true"] {
background: #0066cc;
border-color: #0066cc;
}
.checkbox-control[aria-checked="true"] .checkbox-icon::after {
content: "✓";
color: white;
font-weight: bold;
}
/* Disabled state */
.checkbox-control[aria-disabled="true"] {
opacity: 0.5;
cursor: not-allowed;
}
</style>
<script>
class CustomCheckbox {
constructor(element) {
this.element = element;
this.checked = element.getAttribute('aria-checked') === 'true';
// Event listeners
this.element.addEventListener('click', () => this.toggle());
this.element.addEventListener('keydown', (e) => this.handleKeydown(e));
}
toggle() {
if (this.element.getAttribute('aria-disabled') === 'true') return;
this.checked = !this.checked;
this.element.setAttribute('aria-checked', this.checked);
// Dispatch change event
this.element.dispatchEvent(new CustomEvent('change', {
detail: { checked: this.checked }
}));
}
handleKeydown(e) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
this.toggle();
}
}
}
// Initialize all custom checkboxes
document.querySelectorAll('[role="checkbox"]').forEach(el => {
new CustomCheckbox(el);
});
</script>
Example: Custom toggle switch
<div class="toggle-container">
<button
role="switch"
aria-checked="false"
aria-labelledby="notifications-label"
id="notifications-toggle"
class="toggle-switch">
<span class="toggle-slider" aria-hidden="true"></span>
</button>
<span id="notifications-label">Enable notifications</span>
</div>
<style>
.toggle-switch {
position: relative;
width: 44px;
height: 24px;
background: #ccc;
border: none;
border-radius: 12px;
cursor: pointer;
transition: background 0.3s;
}
.toggle-switch[aria-checked="true"] {
background: #4caf50;
}
.toggle-slider {
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
transition: transform 0.3s;
}
.toggle-switch[aria-checked="true"] .toggle-slider {
transform: translateX(20px);
}
.toggle-switch:focus {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
</style>
<script>
document.getElementById('notifications-toggle').addEventListener('click', function() {
const isChecked = this.getAttribute('aria-checked') === 'true';
this.setAttribute('aria-checked', !isChecked);
});
</script>
Warning: Only create custom form controls when native HTML elements can't meet your needs.
Native controls are tested across browsers/assistive tech and handle edge cases you might miss.
| Custom Control Checklist | Requirement | Test Method |
|---|---|---|
| Keyboard Accessible | All interactions work with keyboard only | Unplug mouse and test |
| Correct Role | Use appropriate ARIA role | Screen reader announces role correctly |
| State Management | ARIA states update dynamically | Screen reader announces state changes |
| Focus Indicator | Clear focus outline on keyboard focus | Tab to element and verify visibility |
| Label Association | Label properly associated with control | Screen reader announces label |
| Touch/Mobile Support | Touch targets ≥ 44x44px | Test on mobile device |
4.6 Form Submission Feedback
| Feedback Type | Trigger | Implementation | ARIA |
|---|---|---|---|
| Loading State | Form submitted, processing | Disable submit button, show spinner | aria-busy="true", aria-live="polite" |
| Success Message | Form processed successfully | Show confirmation message or redirect | role="status" or role="alert" |
| Error Alert | Server error or validation failure | Show error summary, focus first error | role="alert", aria-live="assertive" |
| Progress Indicator | Multi-step form | Progress bar or step indicator | aria-valuenow, aria-valuemin/max |
| Autosave Confirmation | Auto-save triggered | Brief "Saved" message | role="status", aria-live="polite" |
Example: Complete form submission feedback
<form id="contact-form">
<!-- Form fields here -->
<button type="submit" id="submit-btn">
<span class="btn-text">Send Message</span>
<span class="btn-spinner" hidden aria-hidden="true">
<svg class="spinner">...</svg>
</span>
</button>
<!-- Status messages -->
<div
id="form-status"
role="status"
aria-live="polite"
aria-atomic="true"
class="status-message"
hidden>
</div>
</form>
<script>
const form = document.getElementById('contact-form');
const submitBtn = document.getElementById('submit-btn');
const btnText = submitBtn.querySelector('.btn-text');
const btnSpinner = submitBtn.querySelector('.btn-spinner');
const statusDiv = document.getElementById('form-status');
form.addEventListener('submit', async (e) => {
e.preventDefault();
// Set loading state
setLoadingState(true);
try {
// Simulate API call
const response = await fetch('/api/contact', {
method: 'POST',
body: new FormData(form)
});
if (response.ok) {
showSuccess('Message sent successfully! We\'ll respond within 24 hours.');
form.reset();
} else {
showError('Failed to send message. Please try again.');
}
} catch (error) {
showError('Network error. Please check your connection and try again.');
} finally {
setLoadingState(false);
}
});
function setLoadingState(isLoading) {
if (isLoading) {
submitBtn.disabled = true;
submitBtn.setAttribute('aria-busy', 'true');
btnText.textContent = 'Sending...';
btnSpinner.hidden = false;
btnSpinner.removeAttribute('aria-hidden');
} else {
submitBtn.disabled = false;
submitBtn.setAttribute('aria-busy', 'false');
btnText.textContent = 'Send Message';
btnSpinner.hidden = true;
btnSpinner.setAttribute('aria-hidden', 'true');
}
}
function showSuccess(message) {
statusDiv.className = 'status-message success';
statusDiv.innerHTML = `
<svg aria-hidden="true">...checkmark...</svg>
${message}
`;
statusDiv.hidden = false;
// Auto-hide after 5 seconds
setTimeout(() => {
statusDiv.hidden = true;
}, 5000);
}
function showError(message) {
statusDiv.className = 'status-message error';
statusDiv.setAttribute('role', 'alert');
statusDiv.innerHTML = `
<svg aria-hidden="true">...error icon...</svg>
${message}
`;
statusDiv.hidden = false;
}
// Auto-save example
let autoSaveTimeout;
form.addEventListener('input', () => {
clearTimeout(autoSaveTimeout);
autoSaveTimeout = setTimeout(autoSave, 2000);
});
async function autoSave() {
const saveStatus = document.createElement('div');
saveStatus.setAttribute('role', 'status');
saveStatus.setAttribute('aria-live', 'polite');
saveStatus.className = 'autosave-indicator';
saveStatus.textContent = 'Saving draft...';
document.body.appendChild(saveStatus);
// Simulate save
await new Promise(resolve => setTimeout(resolve, 500));
saveStatus.textContent = 'Draft saved';
setTimeout(() => saveStatus.remove(), 2000);
}
</script>
<style>
.status-message {
padding: 12px 16px;
margin: 16px 0;
border-radius: 4px;
display: flex;
align-items: center;
gap: 8px;
}
.status-message.success {
background: #e8f5e9;
color: #2e7d32;
border: 1px solid #4caf50;
}
.status-message.error {
background: #ffebee;
color: #c62828;
border: 1px solid #f44336;
}
.btn-spinner {
display: inline-block;
width: 16px;
height: 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinner {
animation: spin 1s linear infinite;
}
button[aria-busy="true"] {
opacity: 0.7;
cursor: wait;
}
.autosave-indicator {
position: fixed;
bottom: 20px;
right: 20px;
padding: 8px 16px;
background: #333;
color: white;
border-radius: 4px;
font-size: 0.875rem;
}
</style>
| Submission State | Button State | User Action | Announcement |
|---|---|---|---|
| Ready | Enabled, "Submit" | Can submit form | None |
| Submitting | Disabled, "Submitting..." + spinner | Wait for response | "Submitting form" (via aria-live) |
| Success | Re-enabled or hidden | View confirmation or continue | "Form submitted successfully" (role="status") |
| Error | Re-enabled | Fix errors and resubmit | "Error: [message]" (role="alert") |
| Auto-saving | Enabled (background save) | Continue editing | "Draft saved" (role="status", polite) |
Note: Use
role="status" (polite) for non-critical updates like autosave. Use
role="alert" (assertive) for errors that require immediate attention.
Form Accessibility Quick Reference
- Every input must have an associated
<label>- never rely on placeholder alone - Use
aria-describedbyfor help text,aria-labelledbyfor complex labels - Group related fields with
<fieldset>and<legend> - Set
aria-invalid="true"on fields with errors and link to error messages - Show error summary at form top with
role="alert"and focus first error - Mark required fields with
requiredattribute + visual indicator (*, text) - Custom controls need proper ARIA roles, states, and keyboard interaction
- Provide clear feedback during and after submission with
aria-liveregions - Disable submit button during processing and show loading state
- Test forms with keyboard only and screen readers
5. Interactive Components and Widgets
5.1 Button States and Properties
| Button Type | Element | Required Attributes | Use Case |
|---|---|---|---|
| Submit Button | <button type="submit"> |
None (native behavior) | Form submission |
| Reset Button | <button type="reset"> |
None | Clear form fields |
| Regular Button | <button type="button"> |
None | JavaScript actions |
| Link Button | <a href="..." role="button"> |
role="button" (if styled as button) |
Navigation that looks like button |
| Custom Button | <div role="button"> |
role="button", tabindex="0" |
Only when native button impossible |
| Toggle Button | <button aria-pressed> |
aria-pressed="true/false" |
On/off state (bold, italic) |
| Menu Button | <button aria-haspopup> |
aria-haspopup="menu", aria-expanded |
Opens dropdown menu |
Example: Button patterns and states
<!-- Basic buttons -->
<button type="button" onclick="save()">Save</button>
<button type="submit">Submit Form</button>
<button type="reset">Clear</button>
<!-- Disabled button -->
<button disabled>Unavailable Action</button>
<button aria-disabled="true" onclick="showWarning()">
Disabled but still focusable
</button>
<!-- Toggle button (pressed state) -->
<button
type="button"
aria-pressed="false"
onclick="this.setAttribute('aria-pressed',
this.getAttribute('aria-pressed') === 'false' ? 'true' : 'false')">
<span aria-hidden="true">★</span> Favorite
</button>
<!-- Menu button -->
<button
type="button"
aria-haspopup="menu"
aria-expanded="false"
aria-controls="menu-dropdown"
id="menu-button">
Options ▼
</button>
<ul id="menu-dropdown" role="menu" hidden>
<li role="menuitem">Edit</li>
<li role="menuitem">Delete</li>
</ul>
<!-- Button with loading state -->
<button type="submit" id="submit-btn">
<span class="btn-content">Save Changes</span>
<span class="btn-loading" hidden>
<span class="spinner" aria-hidden="true"></span>
Saving...
</span>
</button>
<!-- Icon-only button (requires label) -->
<button type="button" aria-label="Close dialog">
<svg aria-hidden="true">...close icon...</svg>
</button>
<!-- Button with description -->
<button
type="button"
aria-describedby="delete-help">
Delete Account
</button>
<span id="delete-help" class="help-text">
This action cannot be undone
</span>
| ARIA Attribute | Values | Purpose | Example Use |
|---|---|---|---|
| aria-label | String | Accessible name for icon-only buttons | Close, Menu, Search buttons |
| aria-labelledby | ID reference | Label button with another element's text | Button labeled by heading |
| aria-describedby | ID reference(s) | Additional context or help text | Destructive actions with warnings |
| aria-pressed | true/false/mixed | Toggle button state | Bold, Italic, Favorite toggles |
| aria-expanded | true/false | Expandable content state | Dropdown, accordion buttons |
| aria-haspopup | menu/dialog/grid/listbox/tree | Indicates popup type | Menu buttons, comboboxes |
| aria-controls | ID reference | Element controlled by button | Tab panels, dropdowns |
| aria-disabled | true/false | Disabled but focusable (vs disabled attr) | Disabled with tooltip explanation |
| aria-busy | true/false | Loading/processing state | Submit buttons during API call |
Warning: Don't use
<div> or <span> for buttons unless
absolutely necessary. Native <button> elements work better with assistive tech and handle
keyboard interaction automatically.
| disabled vs aria-disabled | Behavior | When to Use |
|---|---|---|
| disabled attribute | Not focusable, not in tab order, grayed out | Standard disabled state - user can't interact |
| aria-disabled="true" | Still focusable, in tab order, can show tooltip | Need to explain why disabled (tooltip on focus) |
5.2 Modal and Dialog Implementation
| Dialog Type | Role | Behavior | Use Case |
|---|---|---|---|
| Modal Dialog | role="dialog" + aria-modal="true" |
Blocks interaction with background, focus trap | Confirmations, forms requiring attention |
| Alert Dialog | role="alertdialog" |
Interrupts workflow, requires response | Critical warnings, errors |
| Non-Modal Dialog | role="dialog" without aria-modal |
Allows background interaction | Inspectors, tool palettes (rare) |
Example: Complete modal dialog implementation
<!-- Trigger button -->
<button type="button" onclick="openDialog()">Open Dialog</button>
<!-- Modal dialog -->
<div
id="my-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
aria-describedby="dialog-desc"
class="modal"
hidden>
<!-- Backdrop -->
<div class="modal-backdrop" onclick="closeDialog()"></div>
<!-- Dialog content -->
<div class="modal-content">
<div class="modal-header">
<h2 id="dialog-title">Confirm Action</h2>
<button
type="button"
aria-label="Close dialog"
onclick="closeDialog()"
class="close-btn">
×
</button>
</div>
<div class="modal-body">
<p id="dialog-desc">
Are you sure you want to delete this item? This action cannot be undone.
</p>
</div>
<div class="modal-footer">
<button type="button" onclick="closeDialog()">Cancel</button>
<button type="button" onclick="confirmAction()" class="btn-danger">
Delete
</button>
</div>
</div>
</div>
<style>
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.modal[hidden] {
display: none;
}
.modal-backdrop {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
}
.modal-content {
position: relative;
background: white;
max-width: 500px;
width: 90%;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
z-index: 1;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #e0e0e0;
}
.close-btn {
background: none;
border: none;
font-size: 28px;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
}
.modal-body {
padding: 20px;
}
.modal-footer {
padding: 16px 20px;
border-top: 1px solid #e0e0e0;
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
<script>
let previousFocus = null;
const dialog = document.getElementById('my-dialog');
function openDialog() {
// Store previous focus
previousFocus = document.activeElement;
// Make background inert
document.body.style.overflow = 'hidden';
// Show dialog
dialog.hidden = false;
// Focus first focusable element (close button or first action)
const firstFocusable = dialog.querySelector('button');
firstFocusable.focus();
// Setup focus trap
setupFocusTrap();
// Listen for Escape key
document.addEventListener('keydown', handleEscape);
}
function closeDialog() {
// Hide dialog
dialog.hidden = true;
// Restore body scroll
document.body.style.overflow = '';
// Remove escape listener
document.removeEventListener('keydown', handleEscape);
// Restore focus
if (previousFocus) {
previousFocus.focus();
}
}
function handleEscape(e) {
if (e.key === 'Escape') {
closeDialog();
}
}
function setupFocusTrap() {
const focusableElements = dialog.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
dialog.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
});
}
function confirmAction() {
console.log('Action confirmed');
closeDialog();
}
</script>
| Modal Requirement | Implementation | WCAG Criteria |
|---|---|---|
| Focus Management | Focus first element on open, restore on close | 2.4.3 Focus Order |
| Focus Trap | Tab cycles only within dialog | 2.1.2 No Keyboard Trap |
| Escape to Close | Escape key dismisses dialog | 2.1.1 Keyboard |
| Accessible Name | aria-labelledby references title |
4.1.2 Name, Role, Value |
| Description | aria-describedby for main content |
4.1.2 Name, Role, Value |
| Background Interaction | aria-modal="true" or inert on background |
2.4.3 Focus Order |
| Initial Focus | Focus close button, first action, or first field | Best practice |
Note: Use
<dialog> HTML element when browser support allows. It handles
focus trap and backdrop automatically. Call dialog.showModal() to open.
5.3 Dropdown and Combobox Patterns
| Pattern | Role | Keyboard | Use Case |
|---|---|---|---|
| Simple Dropdown | role="menu" + role="menuitem" |
↓↑ navigate, Enter select, Esc close | Action menus (Edit, Delete, Share) |
| Select-Only Combobox | role="combobox" + role="listbox" |
↓↑ navigate, Enter select, type-ahead | Country selector, category picker |
| Editable Combobox | role="combobox" + input + role="listbox" |
Type to filter, ↓↑ navigate, Enter select | Autocomplete search, tag input |
| Native Select | <select> element |
Native browser behavior | Preferred when possible - best support |
Example: Accessible dropdown menu
<!-- Dropdown menu button -->
<div class="dropdown">
<button
type="button"
id="menu-button"
aria-haspopup="menu"
aria-expanded="false"
aria-controls="menu-list"
onclick="toggleMenu()">
Actions ▼
</button>
<ul
id="menu-list"
role="menu"
aria-labelledby="menu-button"
hidden>
<li role="none">
<button role="menuitem" onclick="edit()">Edit</button>
</li>
<li role="none">
<button role="menuitem" onclick="duplicate()">Duplicate</button>
</li>
<li role="separator"></li>
<li role="none">
<button role="menuitem" onclick="deleteItem()">Delete</button>
</li>
</ul>
</div>
<script>
const menuButton = document.getElementById('menu-button');
const menuList = document.getElementById('menu-list');
const menuItems = menuList.querySelectorAll('[role="menuitem"]');
let currentIndex = -1;
function toggleMenu() {
const isOpen = menuButton.getAttribute('aria-expanded') === 'true';
if (isOpen) {
closeMenu();
} else {
openMenu();
}
}
function openMenu() {
menuButton.setAttribute('aria-expanded', 'true');
menuList.hidden = false;
currentIndex = 0;
menuItems[0].focus();
// Add event listeners
document.addEventListener('click', handleOutsideClick);
menuList.addEventListener('keydown', handleMenuKeydown);
}
function closeMenu() {
menuButton.setAttribute('aria-expanded', 'false');
menuList.hidden = true;
menuButton.focus();
currentIndex = -1;
// Remove event listeners
document.removeEventListener('click', handleOutsideClick);
menuList.removeEventListener('keydown', handleMenuKeydown);
}
function handleMenuKeydown(e) {
switch(e.key) {
case 'ArrowDown':
e.preventDefault();
currentIndex = (currentIndex + 1) % menuItems.length;
menuItems[currentIndex].focus();
break;
case 'ArrowUp':
e.preventDefault();
currentIndex = (currentIndex - 1 + menuItems.length) % menuItems.length;
menuItems[currentIndex].focus();
break;
case 'Home':
e.preventDefault();
currentIndex = 0;
menuItems[0].focus();
break;
case 'End':
e.preventDefault();
currentIndex = menuItems.length - 1;
menuItems[currentIndex].focus();
break;
case 'Escape':
e.preventDefault();
closeMenu();
break;
case 'Tab':
e.preventDefault();
closeMenu();
break;
}
}
function handleOutsideClick(e) {
if (!menuList.contains(e.target) && e.target !== menuButton) {
closeMenu();
}
}
</script>
Example: Autocomplete combobox with filtering
<!-- Autocomplete combobox -->
<div class="combobox-wrapper">
<label for="country-input">Country</label>
<input
type="text"
id="country-input"
role="combobox"
aria-autocomplete="list"
aria-expanded="false"
aria-controls="country-listbox"
aria-activedescendant=""
autocomplete="off"
placeholder="Type to search...">
<ul
id="country-listbox"
role="listbox"
aria-label="Countries"
hidden>
</ul>
</div>
<script>
const countries = [
'United States', 'United Kingdom', 'Canada', 'Australia',
'Germany', 'France', 'Spain', 'Italy', 'Japan', 'China'
];
const input = document.getElementById('country-input');
const listbox = document.getElementById('country-listbox');
let currentOption = -1;
input.addEventListener('input', (e) => {
const value = e.target.value.toLowerCase();
if (!value) {
closeListbox();
return;
}
// Filter countries
const filtered = countries.filter(country =>
country.toLowerCase().includes(value)
);
// Populate listbox
listbox.innerHTML = filtered.map((country, index) => `
<li
role="option"
id="option-${index}"
onclick="selectOption('${country}')">
${country}
</li>
`).join('');
if (filtered.length > 0) {
openListbox();
} else {
closeListbox();
}
});
input.addEventListener('keydown', (e) => {
const options = listbox.querySelectorAll('[role="option"]');
switch(e.key) {
case 'ArrowDown':
e.preventDefault();
if (input.getAttribute('aria-expanded') === 'false') {
openListbox();
}
currentOption = Math.min(currentOption + 1, options.length - 1);
updateActiveDescendant(options);
break;
case 'ArrowUp':
e.preventDefault();
currentOption = Math.max(currentOption - 1, 0);
updateActiveDescendant(options);
break;
case 'Enter':
e.preventDefault();
if (currentOption >= 0 && options[currentOption]) {
selectOption(options[currentOption].textContent.trim());
}
break;
case 'Escape':
closeListbox();
break;
}
});
function updateActiveDescendant(options) {
options.forEach((opt, idx) => {
opt.classList.toggle('selected', idx === currentOption);
});
if (options[currentOption]) {
input.setAttribute('aria-activedescendant', options[currentOption].id);
}
}
function openListbox() {
input.setAttribute('aria-expanded', 'true');
listbox.hidden = false;
currentOption = -1;
}
function closeListbox() {
input.setAttribute('aria-expanded', 'false');
input.setAttribute('aria-activedescendant', '');
listbox.hidden = true;
currentOption = -1;
}
function selectOption(value) {
input.value = value;
closeListbox();
input.focus();
}
// Close on outside click
document.addEventListener('click', (e) => {
if (!input.contains(e.target) && !listbox.contains(e.target)) {
closeListbox();
}
});
</script>
| Combobox Attribute | Value | Purpose |
|---|---|---|
| role="combobox" | - | Identifies the input as a combobox |
| aria-autocomplete | list/inline/both/none | Indicates autocomplete behavior |
| aria-expanded | true/false | Whether listbox is visible |
| aria-controls | ID of listbox | Links input to dropdown list |
| aria-activedescendant | ID of active option | Current highlighted option (virtual focus) |
| aria-owns | ID(s) | Alternative to aria-controls (older pattern) |
Warning: Combobox is one of the most complex ARIA patterns. Use native
<select> or <datalist> when possible. Only implement custom combobox when
absolutely necessary.
5.4 Tab Panel Components
| Element | Role | Required Attributes | Purpose |
|---|---|---|---|
| Tab Container | role="tablist" |
aria-label or aria-labelledby |
Container for tabs |
| Individual Tab | role="tab" |
aria-selected, aria-controls, tabindex |
Tab trigger button |
| Tab Panel | role="tabpanel" |
aria-labelledby, tabindex="0" |
Content area for active tab |
Example: Complete tab panel implementation
<div class="tabs-container">
<!-- Tab list -->
<div role="tablist" aria-label="Content sections">
<button
role="tab"
aria-selected="true"
aria-controls="panel-1"
id="tab-1"
tabindex="0">
Overview
</button>
<button
role="tab"
aria-selected="false"
aria-controls="panel-2"
id="tab-2"
tabindex="-1">
Features
</button>
<button
role="tab"
aria-selected="false"
aria-controls="panel-3"
id="tab-3"
tabindex="-1">
Pricing
</button>
</div>
<!-- Tab panels -->
<div
role="tabpanel"
id="panel-1"
aria-labelledby="tab-1"
tabindex="0">
<h3>Overview Content</h3>
<p>This is the overview section...</p>
</div>
<div
role="tabpanel"
id="panel-2"
aria-labelledby="tab-2"
tabindex="0"
hidden>
<h3>Features Content</h3>
<p>This is the features section...</p>
</div>
<div
role="tabpanel"
id="panel-3"
aria-labelledby="tab-3"
tabindex="0"
hidden>
<h3>Pricing Content</h3>
<p>This is the pricing section...</p>
</div>
</div>
<style>
.tabs-container {
border: 1px solid #ddd;
border-radius: 4px;
}
[role="tablist"] {
display: flex;
border-bottom: 2px solid #ddd;
background: #f5f5f5;
}
[role="tab"] {
padding: 12px 24px;
border: none;
background: transparent;
cursor: pointer;
position: relative;
transition: all 0.2s;
}
[role="tab"]:hover {
background: #e0e0e0;
}
[role="tab"]:focus {
outline: 2px solid #0066cc;
outline-offset: -2px;
z-index: 1;
}
[role="tab"][aria-selected="true"] {
background: white;
border-bottom: 3px solid #0066cc;
font-weight: 600;
color: #0066cc;
}
[role="tabpanel"] {
padding: 20px;
}
[role="tabpanel"]:focus {
outline: 2px solid #0066cc;
outline-offset: -2px;
}
</style>
<script>
class TabWidget {
constructor(tablist) {
this.tablist = tablist;
this.tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
this.panels = this.tabs.map(tab =>
document.getElementById(tab.getAttribute('aria-controls'))
);
this.currentIndex = this.tabs.findIndex(
tab => tab.getAttribute('aria-selected') === 'true'
);
this.setupEventListeners();
}
setupEventListeners() {
this.tabs.forEach((tab, index) => {
tab.addEventListener('click', () => this.selectTab(index));
tab.addEventListener('keydown', (e) => this.handleKeydown(e, index));
});
}
selectTab(index) {
// Deselect all tabs
this.tabs.forEach((tab, i) => {
const isSelected = i === index;
tab.setAttribute('aria-selected', isSelected);
tab.setAttribute('tabindex', isSelected ? '0' : '-1');
// Show/hide panels
if (this.panels[i]) {
this.panels[i].hidden = !isSelected;
}
});
// Focus selected tab
this.tabs[index].focus();
this.currentIndex = index;
}
handleKeydown(e, currentIndex) {
let newIndex = currentIndex;
switch(e.key) {
case 'ArrowLeft':
e.preventDefault();
newIndex = (currentIndex - 1 + this.tabs.length) % this.tabs.length;
this.selectTab(newIndex);
break;
case 'ArrowRight':
e.preventDefault();
newIndex = (currentIndex + 1) % this.tabs.length;
this.selectTab(newIndex);
break;
case 'Home':
e.preventDefault();
this.selectTab(0);
break;
case 'End':
e.preventDefault();
this.selectTab(this.tabs.length - 1);
break;
}
}
}
// Initialize all tab widgets
document.querySelectorAll('[role="tablist"]').forEach(tablist => {
new TabWidget(tablist);
});
</script>
| Tab Pattern Best Practice | Implementation | Reason |
|---|---|---|
| Roving tabindex | Selected tab: tabindex="0", others: tabindex="-1" |
Only one tab in tab order at a time |
| Arrow Key Navigation | Left/Right arrows move between tabs | Standard tab pattern expectation |
| Automatic Activation | Arrow keys both focus AND activate tab | Recommended (vs manual activation) |
| Home/End Keys | Jump to first/last tab | Convenience for many tabs |
| Panel Focusable | Panels have tabindex="0" |
Allows keyboard users to scroll panel |
| aria-selected | Only selected tab has aria-selected="true" |
Communicates state to screen readers |
Note: There are two tab activation patterns: Automatic (arrow
keys activate immediately - recommended) and Manual (arrow keys focus,
Enter/Space activates). Automatic is more common and preferred.
5.5 Accordion and Disclosure Widgets
| Pattern | HTML Element | ARIA Pattern | Use Case |
|---|---|---|---|
| Native Disclosure | <details> + <summary> |
None (built-in) | Simple show/hide content (preferred) |
| ARIA Disclosure | <button aria-expanded> |
aria-expanded, aria-controls |
Custom styled disclosure |
| Accordion | Multiple disclosure widgets | Same as disclosure, grouped | FAQ sections, settings panels |
Example: Native details/summary (preferred)
<!-- Native disclosure (best accessibility) -->
<details>
<summary>What is accessibility?</summary>
<p>
Accessibility ensures that websites and applications can be used by
everyone, including people with disabilities who may use assistive
technologies like screen readers.
</p>
</details>
<details open>
<summary>Why is it important?</summary>
<p>
It's not just a legal requirement - it makes your content available
to a wider audience and improves usability for everyone.
</p>
</details>
<style>
details {
border: 1px solid #ddd;
border-radius: 4px;
padding: 12px;
margin-bottom: 8px;
}
summary {
cursor: pointer;
font-weight: 600;
padding: 8px 0;
list-style: none; /* Remove default marker */
display: flex;
align-items: center;
}
/* Custom marker */
summary::before {
content: "▶";
margin-right: 8px;
transition: transform 0.2s;
display: inline-block;
}
details[open] summary::before {
transform: rotate(90deg);
}
/* Remove default marker in WebKit */
summary::-webkit-details-marker {
display: none;
}
summary:focus {
outline: 2px solid #0066cc;
outline-offset: 2px;
border-radius: 2px;
}
details p {
margin-top: 12px;
padding-left: 24px;
}
</style>
Example: Custom accordion with ARIA
<!-- Custom accordion -->
<div class="accordion">
<h3>
<button
type="button"
aria-expanded="false"
aria-controls="section1"
id="accordion1"
class="accordion-trigger">
<span class="accordion-title">Personal Information</span>
<span class="accordion-icon" aria-hidden="true">+</span>
</button>
</h3>
<div
id="section1"
role="region"
aria-labelledby="accordion1"
class="accordion-panel"
hidden>
<p>Content for personal information section...</p>
</div>
<h3>
<button
type="button"
aria-expanded="false"
aria-controls="section2"
id="accordion2"
class="accordion-trigger">
<span class="accordion-title">Account Settings</span>
<span class="accordion-icon" aria-hidden="true">+</span>
</button>
</h3>
<div
id="section2"
role="region"
aria-labelledby="accordion2"
class="accordion-panel"
hidden>
<p>Content for account settings section...</p>
</div>
<h3>
<button
type="button"
aria-expanded="false"
aria-controls="section3"
id="accordion3"
class="accordion-trigger">
<span class="accordion-title">Privacy Options</span>
<span class="accordion-icon" aria-hidden="true">+</span>
</button>
</h3>
<div
id="section3"
role="region"
aria-labelledby="accordion3"
class="accordion-panel"
hidden>
<p>Content for privacy options section...</p>
</div>
</div>
<style>
.accordion {
border: 1px solid #ddd;
border-radius: 4px;
}
.accordion h3 {
margin: 0;
border-bottom: 1px solid #ddd;
}
.accordion h3:last-of-type {
border-bottom: none;
}
.accordion-trigger {
width: 100%;
padding: 16px;
border: none;
background: white;
text-align: left;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 16px;
transition: background 0.2s;
}
.accordion-trigger:hover {
background: #f5f5f5;
}
.accordion-trigger:focus {
outline: 2px solid #0066cc;
outline-offset: -2px;
z-index: 1;
}
.accordion-trigger[aria-expanded="true"] {
background: #f0f8ff;
}
.accordion-icon {
font-size: 24px;
font-weight: bold;
transition: transform 0.2s;
}
.accordion-trigger[aria-expanded="true"] .accordion-icon {
transform: rotate(45deg);
}
.accordion-panel {
padding: 16px;
border-top: 1px solid #ddd;
}
.accordion-panel[hidden] {
display: none;
}
</style>
<script>
class Accordion {
constructor(element) {
this.accordion = element;
this.triggers = Array.from(element.querySelectorAll('.accordion-trigger'));
this.allowMultiple = element.hasAttribute('data-allow-multiple');
this.triggers.forEach((trigger, index) => {
trigger.addEventListener('click', () => this.toggle(index));
});
}
toggle(index) {
const trigger = this.triggers[index];
const panel = document.getElementById(trigger.getAttribute('aria-controls'));
const isExpanded = trigger.getAttribute('aria-expanded') === 'true';
// Close other panels if not allowing multiple
if (!this.allowMultiple && !isExpanded) {
this.triggers.forEach((t, i) => {
if (i !== index) {
this.collapse(i);
}
});
}
// Toggle current panel
if (isExpanded) {
this.collapse(index);
} else {
this.expand(index);
}
}
expand(index) {
const trigger = this.triggers[index];
const panel = document.getElementById(trigger.getAttribute('aria-controls'));
trigger.setAttribute('aria-expanded', 'true');
panel.hidden = false;
}
collapse(index) {
const trigger = this.triggers[index];
const panel = document.getElementById(trigger.getAttribute('aria-controls'));
trigger.setAttribute('aria-expanded', 'false');
panel.hidden = true;
}
}
// Initialize all accordions
document.querySelectorAll('.accordion').forEach(accordion => {
new Accordion(accordion);
});
</script>
| Accordion Variant | Behavior | Implementation |
|---|---|---|
| Single Expansion | Opening one section closes others | Default accordion behavior |
| Multiple Expansion | Multiple sections can be open | Add data-allow-multiple attribute |
| All Collapsed | All sections can be closed | Default - no section required to be open |
| Always One Open | At least one section must be open | Prevent closing last open section |
Note: Use native
<details>/<summary> when possible - it requires no
JavaScript, works in all modern browsers, and has excellent accessibility support.
5.6 Custom Widget Development
| Development Step | Consideration | Resources |
|---|---|---|
| 1. Check Native Options | Can native HTML element meet needs? | HTML5 elements, form controls |
| 2. Review ARIA Patterns | Is there an established ARIA pattern? | WAI-ARIA Authoring Practices Guide (APG) |
| 3. Define Keyboard Interaction | What keys should do what? | Follow APG keyboard patterns |
| 4. Implement ARIA | Roles, states, properties | ARIA specification |
| 5. Add Focus Management | Where should focus go when? | Focus trap, roving tabindex patterns |
| 6. Test with AT | Screen readers, keyboard only | NVDA, JAWS, VoiceOver, TalkBack |
Example: Custom widget checklist and template
// Custom Widget Development Template
class CustomWidget {
constructor(element) {
this.element = element;
this.isInitialized = false;
// Validate element exists
if (!this.element) {
console.error('Widget element not found');
return;
}
this.init();
}
init() {
// 1. Set ARIA roles and initial state
this.setupARIA();
// 2. Setup keyboard interaction
this.setupKeyboard();
// 3. Setup mouse/touch interaction
this.setupPointer();
// 4. Setup focus management
this.setupFocus();
this.isInitialized = true;
}
setupARIA() {
// Set role if not already present
if (!this.element.hasAttribute('role')) {
this.element.setAttribute('role', 'widget-role');
}
// Set initial ARIA states
this.element.setAttribute('aria-label', 'Widget name');
this.element.setAttribute('tabindex', '0');
// Set dynamic ARIA properties
this.updateARIAState();
}
updateARIAState() {
// Update ARIA states based on widget state
// Example: aria-expanded, aria-selected, aria-checked, etc.
}
setupKeyboard() {
this.element.addEventListener('keydown', (e) => {
switch(e.key) {
case 'Enter':
case ' ':
e.preventDefault();
this.activate();
break;
case 'ArrowUp':
case 'ArrowDown':
case 'ArrowLeft':
case 'ArrowRight':
e.preventDefault();
this.navigate(e.key);
break;
case 'Home':
e.preventDefault();
this.navigateToFirst();
break;
case 'End':
e.preventDefault();
this.navigateToLast();
break;
case 'Escape':
e.preventDefault();
this.close();
break;
case 'Tab':
// Allow default tab behavior unless in focus trap
if (this.isFocusTrapped) {
e.preventDefault();
this.handleTabInTrap(e.shiftKey);
}
break;
}
});
}
setupPointer() {
// Click events
this.element.addEventListener('click', (e) => {
this.handleClick(e);
});
// Touch events for mobile
this.element.addEventListener('touchstart', (e) => {
this.handleTouch(e);
}, { passive: true });
}
setupFocus() {
// Focus events
this.element.addEventListener('focus', () => {
this.onFocus();
});
this.element.addEventListener('blur', () => {
this.onBlur();
});
}
// Widget-specific methods
activate() {
console.log('Widget activated');
this.updateARIAState();
}
navigate(direction) {
console.log('Navigate:', direction);
}
navigateToFirst() {
console.log('Navigate to first');
}
navigateToLast() {
console.log('Navigate to last');
}
close() {
console.log('Widget closed');
}
handleClick(e) {
console.log('Clicked:', e.target);
}
handleTouch(e) {
console.log('Touch:', e.target);
}
onFocus() {
this.element.classList.add('focused');
}
onBlur() {
this.element.classList.remove('focused');
}
handleTabInTrap(isShiftTab) {
// Implement focus trap logic
}
// Public API
destroy() {
// Clean up event listeners
this.element.removeAttribute('role');
this.element.removeAttribute('tabindex');
this.isInitialized = false;
}
}
// Usage
const widget = new CustomWidget(document.getElementById('my-widget'));
| Widget Checklist | ✓ Verified | Test Method |
|---|---|---|
| Keyboard Accessible | ☐ | Unplug mouse, navigate with keyboard only |
| Screen Reader Compatible | ☐ | Test with NVDA/JAWS/VoiceOver |
| Proper ARIA Roles | ☐ | Verify with browser DevTools |
| States Update Dynamically | ☐ | Check ARIA attributes change with state |
| Focus Visible | ☐ | Clear focus indicator on all elements |
| Focus Management | ☐ | Focus moves logically through widget |
| Touch Targets ≥44px | ☐ | Test on mobile device |
| Works Without JS | ☐ | Progressive enhancement (if possible) |
| Respects User Preferences | ☐ | Test prefers-reduced-motion, color schemes |
| Error States Announced | ☐ | Use aria-live or role="alert" |
| Cross-Browser Tested | ☐ | Chrome, Firefox, Safari, Edge |
| Mobile AT Tested | ☐ | iOS VoiceOver, Android TalkBack |
| Common Widget Types | ARIA Pattern Reference | Complexity |
|---|---|---|
| Accordion | button[aria-expanded] + region |
Low |
| Tabs | tablist + tab + tabpanel |
Medium |
| Dialog/Modal | dialog + focus trap |
Medium |
| Dropdown Menu | menu + menuitem |
Medium |
| Combobox | combobox + listbox + active descendant |
High |
| Slider | slider + aria-valuemin/max/now |
Medium |
| Data Grid | grid + row + gridcell |
High |
| Tree View | tree + treeitem + nested structure |
High |
| Tooltip | tooltip + aria-describedby |
Low |
| Carousel | region + group + rotation controls |
Medium-High |
Warning: Custom widgets are difficult to get right. Before building custom, ask: Can native HTML solve this? Then: Can existing library solve
this? Only build custom when absolutely necessary.
Resources:
- ARIA Authoring Practices Guide (APG): w3.org/WAI/ARIA/apg/ - Definitive patterns for all widgets
- Inclusive Components: inclusive-components.design - Real-world accessible component patterns
- a11y-dialog: github.com/KittyGiraudel/a11y-dialog - Reference modal implementation
- Reach UI: reach.tech - Accessible React component library to study
Interactive Components Quick Reference
- Always use
<button>for buttons - addtype="button"to prevent form submission - Toggle buttons need
aria-pressed, menu buttons needaria-expanded - Modals require:
role="dialog",aria-modal="true", focus trap, Escape to close - Focus first element on modal open, restore focus on close
- Use native
<select>or<datalist>instead of custom combobox when possible - Dropdown menus use
role="menu"with arrow key navigation - Tabs: roving tabindex, Left/Right arrow navigation, automatic activation recommended
- Prefer
<details>/<summary>for accordions - native and accessible - Custom widgets must implement keyboard interaction per ARIA Authoring Practices Guide
- Test all widgets with keyboard only and multiple screen readers
6. Media and Content Accessibility
6.1 Alternative Text Best Practices
| Image Type | Alt Text Pattern | Example | Rationale |
|---|---|---|---|
| Informative Image | Describe content/function | alt="Bar chart showing 40% increase in sales" |
Conveys essential information |
| Decorative Image | Empty alt attribute | alt="" or role="presentation" |
Screen reader ignores image |
| Functional Image | Describe action/purpose | alt="Search", alt="Submit form" |
Explains what happens when clicked |
| Image Link | Describe destination | alt="Go to home page" |
Where link takes user |
| Logo | Company/product name | alt="Acme Corporation" |
Identifies brand |
| Image with Adjacent Text | Complement, not duplicate | Text: "CEO Jane Smith" → alt="Portrait photo" |
Avoid redundancy |
| Complex Image/Chart | Short alt + long description | alt="Sales data" + aria-describedby |
Summary in alt, details elsewhere |
| Image Map | Alt on area elements | <area alt="California"> |
Each clickable region labeled |
Example: Alt text patterns for different scenarios
<!-- Informative image -->
<img src="chart.png" alt="Quarterly sales increased 35% from Q1 to Q2">
<!-- Decorative image (empty alt) -->
<img src="divider.png" alt="">
<img src="pattern.png" role="presentation">
<!-- Functional image (button/link) -->
<button>
<img src="trash.png" alt="Delete item">
</button>
<a href="/">
<img src="logo.png" alt="Return to homepage">
</a>
<!-- Logo in header -->
<header>
<a href="/">
<img src="logo.svg" alt="Acme Corp">
</a>
</header>
<!-- Image with caption (don't duplicate) -->
<figure>
<img src="sunset.jpg" alt="Golden sunset over mountain range">
<figcaption>
Sunset at Rocky Mountain National Park, June 2024
</figcaption>
</figure>
<!-- Complex image with long description -->
<img
src="org-chart.png"
alt="Company organizational chart"
aria-describedby="chart-desc">
<div id="chart-desc">
<p>Hierarchical structure showing CEO at top, reporting to
three Vice Presidents for Engineering, Sales, and Marketing...</p>
</div>
<!-- Image map -->
<img src="map.png" alt="United States map" usemap="#us-map">
<map name="us-map">
<area shape="poly" coords="..." href="/ca" alt="California">
<area shape="poly" coords="..." href="/tx" alt="Texas">
</map>
<!-- Background image with CSS (provide text alternative) -->
<div class="hero" style="background-image: url('hero.jpg')"
role="img" aria-label="Team collaborating in modern office">
</div>
| Alt Text Guidelines | Do | Don't |
|---|---|---|
| Length | Concise (typically <150 chars) | Write lengthy paragraphs in alt |
| Phrases to Avoid | Start directly with content | "Image of...", "Picture of...", "Graphic of..." |
| Punctuation | Use proper punctuation | Omit periods or use all caps |
| Context | Match surrounding content context | Provide irrelevant details |
| File Names | Write meaningful description | Use file names: "IMG_1234.jpg" |
| Redundancy | Complement adjacent text | Duplicate nearby text verbatim |
| Decorative | Use alt="" |
Omit alt attribute entirely |
Warning: Missing alt attributes cause screen readers to announce the file name. Always include
alt="" for decorative images rather than omitting the attribute.
6.2 Video Captions and Transcripts
| Caption Type | Format | Use Case | WCAG Level |
|---|---|---|---|
| Closed Captions (CC) | WebVTT (.vtt), SRT (.srt) | User can toggle on/off - includes dialogue + sounds | AA (prerecorded) |
| Open Captions | Burned into video | Always visible, cannot be disabled | AA (prerecorded) |
| Subtitles | WebVTT (.vtt) | Translation for different languages | Not required |
| Full Transcript | HTML text | Complete text of all audio + visual descriptions | AAA (recommended) |
| Live Captions | Real-time text stream | Live broadcasts, meetings | AA (live content) |
Example: Video with captions and transcript
<!-- Video with multiple caption tracks -->
<video controls width="640" height="360">
<source src="video.mp4" type="video/mp4">
<source src="video.webm" type="video/webm">
<!-- English captions (default) -->
<track
kind="captions"
src="captions-en.vtt"
srclang="en"
label="English"
default>
<!-- Spanish subtitles -->
<track
kind="subtitles"
src="subtitles-es.vtt"
srclang="es"
label="Español">
<!-- Audio description track -->
<track
kind="descriptions"
src="descriptions.vtt"
srclang="en"
label="Audio Descriptions">
<!-- Fallback for browsers without video support -->
<p>Your browser doesn't support HTML5 video.
<a href="video.mp4">Download the video</a> or
<a href="#transcript">read the transcript</a>.
</p>
</video>
<!-- Transcript section -->
<details id="transcript">
<summary>Video Transcript</summary>
<div>
<h3>Introduction to Web Accessibility</h3>
<p><strong>[00:00]</strong></p>
<p><strong>Narrator:</strong> Welcome to our guide on web accessibility.</p>
<p><strong>[00:05] [Background music plays]</strong></p>
<p><strong>Narrator:</strong> In this video, we'll explore why accessibility matters.</p>
<p><strong>[00:10] [Visual: Statistics chart appears]</strong></p>
<p><strong>Narrator:</strong> Over 1 billion people worldwide have disabilities.</p>
<p><strong>[End of transcript]</strong></p>
</div>
</details>
Example: WebVTT caption file format
WEBVTT
NOTE This is a caption file for accessibility demo
00:00:00.000 --> 00:00:03.000
Welcome to our guide on web accessibility.
00:00:03.500 --> 00:00:07.000
[Background music plays]
00:00:07.000 --> 00:00:11.000
In this video, we'll explore why accessibility matters.
00:00:11.500 --> 00:00:15.000
<v Narrator>Over 1 billion people worldwide have disabilities.</v>
00:00:15.500 --> 00:00:19.000
[Upbeat music]
Making websites accessible benefits everyone.
NOTE You can add speaker names with <v Speaker Name>
NOTE You can add positioning with align:start, align:middle, align:end
| Caption Content Requirements | Include | Example |
|---|---|---|
| Dialogue | All spoken words | "Welcome to our presentation" |
| Speaker Identification | Who is speaking (when not obvious) | "<v Sarah>Let me explain...</v>" |
| Sound Effects | Important non-speech sounds | "[Door slams]", "[Phone rings]" |
| Music Cues | Mood-setting or significant music | "[Suspenseful music]", "[♪ Jazz playing ♪]" |
| Tone/Manner | How something is said (when important) | "[Sarcastically] That's just great." |
| Off-Screen Audio | Sounds from outside frame | "[Voice from hallway] Is anyone there?" |
Note: Captions and subtitles are different. Captions include
dialogue + sound effects for deaf/hard-of-hearing. Subtitles are translations of
dialogue only.
6.3 Audio Descriptions Implementation
| Audio Description Type | Implementation | Use Case | WCAG Level |
|---|---|---|---|
| Standard Audio Description | Narration fits in existing pauses | Videos with natural dialogue breaks | AA (prerecorded) |
| Extended Audio Description | Video pauses for longer descriptions | Videos without adequate pauses | AAA |
| Descriptive Transcript | Text with visual descriptions | Alternative to audio description | AAA |
| Text Track (VTT descriptions) | <track kind="descriptions"> |
Browser-supported descriptions | Modern approach |
| Separate Audio Track | Alternate video file with descriptions | When track element not supported | Fallback option |
Example: Video with audio descriptions
<!-- Video with audio description track -->
<video id="described-video" controls>
<source src="presentation.mp4" type="video/mp4">
<!-- Captions -->
<track
kind="captions"
src="captions.vtt"
srclang="en"
label="English Captions"
default>
<!-- Audio descriptions -->
<track
kind="descriptions"
src="audio-descriptions.vtt"
srclang="en"
label="Audio Descriptions">
</video>
<!-- Toggle for audio descriptions -->
<button onclick="toggleDescriptions()">
Toggle Audio Descriptions
</button>
<script>
const video = document.getElementById('described-video');
const descTrack = video.textTracks[1]; // descriptions track
function toggleDescriptions() {
if (descTrack.mode === 'showing') {
descTrack.mode = 'hidden';
} else {
descTrack.mode = 'showing';
}
}
// Listen for description cues
descTrack.addEventListener('cuechange', () => {
const cue = descTrack.activeCues[0];
if (cue) {
// Optionally announce via screen reader
announceToScreenReader(cue.text);
}
});
function announceToScreenReader(text) {
const announcement = document.createElement('div');
announcement.setAttribute('role', 'status');
announcement.setAttribute('aria-live', 'polite');
announcement.className = 'sr-only';
announcement.textContent = text;
document.body.appendChild(announcement);
setTimeout(() => announcement.remove(), 1000);
}
</script>
Example: Audio description VTT file
WEBVTT
NOTE Audio descriptions for presentation video
00:00:05.000 --> 00:00:08.000
A woman in a blue suit stands at a podium.
00:00:15.000 --> 00:00:18.000
She gestures to a graph showing upward trending data.
00:00:28.000 --> 00:00:32.000
The camera pans to audience members taking notes.
00:00:45.000 --> 00:00:49.000
A pie chart appears on screen showing market distribution.
00:01:02.000 --> 00:01:06.000
The speaker smiles and nods to acknowledge applause.
| What to Describe | Priority | Example Description |
|---|---|---|
| Actions | High | "Sarah picks up the phone" |
| Settings/Scenes | High | "Modern office with glass walls" |
| Characters/People | High | "A man in his 40s with gray hair" |
| On-Screen Text | High | "Title card: Five Years Later" |
| Facial Expressions | Medium | "She frowns and looks away" |
| Clothing/Appearance | Medium | "Wearing a red dress and pearl necklace" |
| Atmosphere/Mood | Low | "The dimly lit room creates tension" |
Warning: Audio descriptions must fit between dialogue/narration without overlapping. If there
isn't enough time, consider extended audio descriptions (which pause video) or detailed transcripts.
| Media Accessibility Checklist | Video | Audio Only |
|---|---|---|
| Captions/Subtitles | ✓ Required (AA) | N/A |
| Audio Descriptions | ✓ Required (AA) | N/A |
| Transcript | Recommended (AAA) | ✓ Required (AA) |
| Keyboard Controls | ✓ Required | ✓ Required |
| Pause/Stop | ✓ Required | ✓ Required |
| Volume Control | ✓ Required | ✓ Required |
| No Auto-play | ✓ Best practice | ✓ Best practice |
6.4 Complex Images and Data Visualization
| Visualization Type | Accessibility Strategy | Implementation |
|---|---|---|
| Charts/Graphs | Short alt + detailed description + data table | aria-describedby + <table> |
| Infographics | Structured HTML alternative | Headings, lists, and semantic markup |
| Diagrams | Long description or labeled version | <details> or separate page |
| Maps | Interactive alternative or text description | List of locations with links/coordinates |
| Flowcharts | Text-based outline of flow | Ordered/nested lists showing steps |
| Interactive Dashboards | Keyboard navigation + ARIA live regions | Announce data updates, allow filtering |
Example: Chart with multiple accessibility layers
<!-- Complex chart with comprehensive accessibility -->
<figure>
<img
src="sales-chart.png"
alt="Bar chart: Monthly sales for 2024"
aria-describedby="chart-summary chart-data">
<figcaption id="chart-summary">
Sales increased steadily throughout 2024, starting at $50K in January
and reaching $120K by December, with notable spikes in June ($95K)
and November ($115K).
</figcaption>
<!-- Detailed data table (can be in details/summary) -->
<details>
<summary>View detailed data table</summary>
<table id="chart-data">
<caption>Monthly Sales Data 2024</caption>
<thead>
<tr>
<th scope="col">Month</th>
<th scope="col">Sales</th>
<th scope="col">Change</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">January</th>
<td>$50,000</td>
<td>-</td>
</tr>
<tr>
<th scope="row">February</th>
<td>$58,000</td>
<td>+16%</td>
</tr>
<tr>
<th scope="row">March</th>
<td>$65,000</td>
<td>+12%</td>
</tr>
<!-- More rows... -->
</tbody>
</table>
</details>
</figure>
Example: Accessible SVG chart with ARIA
<!-- Accessible SVG chart -->
<svg
role="img"
aria-labelledby="chart-title chart-desc"
width="600"
height="400">
<title id="chart-title">Quarterly Revenue Growth</title>
<desc id="chart-desc">
Bar chart showing revenue growth across four quarters.
Q1: $2.5M, Q2: $3.1M, Q3: $3.8M, Q4: $4.2M.
Revenue increased 68% from Q1 to Q4.
</desc>
<!-- Chart elements -->
<g role="list" aria-label="Quarterly data">
<rect role="listitem" aria-label="Q1: $2.5 million"
x="50" y="200" width="100" height="150" fill="#0066cc"></rect>
<rect role="listitem" aria-label="Q2: $3.1 million"
x="175" y="150" width="100" height="200" fill="#0066cc"></rect>
<rect role="listitem" aria-label="Q3: $3.8 million"
x="300" y="100" width="100" height="250" fill="#0066cc"></rect>
<rect role="listitem" aria-label="Q4: $4.2 million"
x="425" y="50" width="100" height="300" fill="#0066cc"></rect>
</g>
<!-- Decorative elements hidden from screen readers -->
<g aria-hidden="true">
<!-- Axis lines, grid, etc. -->
</g>
</svg>
| Data Viz Best Practice | Why | Implementation |
|---|---|---|
| Provide Data Table | Gives precise values screen readers can navigate | Hidden by default, shown in <details> |
| Short + Long Description | Alt gives overview, long desc gives details | alt + aria-describedby |
| Sufficient Color Contrast | Users with low vision need to see data points | 4.5:1 minimum for data elements |
| Don't Rely on Color Alone | Color blind users can't distinguish colors | Use patterns, labels, shapes too |
| Keyboard Navigable | Users need to explore data without mouse | Focus indicators on data points |
| Announce Dynamic Changes | Screen reader users need update notifications | aria-live regions for data updates |
Note: For complex interactive visualizations (D3.js, Chart.js), consider using accessible
charting libraries like Highcharts (with accessibility module) or provide comprehensive data tables as
alternatives.
6.5 Icon and SVG Accessibility
| Icon Type | Treatment | Code Pattern | Screen Reader Output |
|---|---|---|---|
| Decorative Icon | Hide from AT | aria-hidden="true" |
Ignored |
| Informative Icon | Provide text alternative | role="img" + aria-label |
Announces label |
| Icon in Button | Label button, hide icon | aria-label on button, aria-hidden on icon |
Announces button label |
| Icon with Adjacent Text | Hide icon from AT | aria-hidden="true" on icon |
Announces text only |
| Icon Font | Add text or ARIA label | <span aria-label="Save"><i>💾</i></span> |
Announces label |
| SVG Icon | Use <title> or aria-label |
<svg><title>Save</title></svg> |
Announces title |
Example: Icon accessibility patterns
<!-- Decorative icon with text -->
<button>
<svg aria-hidden="true" class="icon">...</svg>
Save Document
</button>
<!-- Icon-only button (needs label) -->
<button aria-label="Delete item">
<svg aria-hidden="true">
<use href="#trash-icon"></use>
</svg>
</button>
<!-- Informative icon (standalone) -->
<svg role="img" aria-label="Warning">
<use href="#warning-icon"></use>
</svg>
<!-- SVG with title and desc -->
<svg role="img" aria-labelledby="save-title save-desc">
<title id="save-title">Save</title>
<desc id="save-desc">Floppy disk icon</desc>
<path d="M..."></path>
</svg>
<!-- Font icon (FontAwesome, Material Icons) -->
<span class="icon-wrapper">
<i class="fas fa-home" aria-hidden="true"></i>
<span class="sr-only">Home</span>
</span>
<!-- Emoji icons (problematic - provide text alternative) -->
<span role="img" aria-label="thumbs up">👍</span>
<!-- Status icon with text -->
<span class="status">
<svg aria-hidden="true" class="icon-success">...</svg>
<span>Successfully saved</span>
</span>
<!-- Icon link -->
<a href="/settings" aria-label="Settings">
<svg aria-hidden="true">
<use href="#gear-icon"></use>
</svg>
</a>
<!-- Icon sprite definition (hidden) -->
<svg style="display: none;">
<symbol id="trash-icon" viewBox="0 0 24 24">
<path d="M..."></path>
</symbol>
<symbol id="warning-icon" viewBox="0 0 24 24">
<path d="M..."></path>
</symbol>
</svg>
| SVG Accessibility Technique | Code | Use Case |
|---|---|---|
| <title> element | <svg><title>Icon name</title>...</svg> |
Simple SVG with name only |
| <title> + <desc> | <title>...</title><desc>...</desc> |
SVG needing detailed description |
| role="img" | <svg role="img"> |
Ensure SVG treated as image |
| aria-labelledby | aria-labelledby="title-id desc-id" |
Link to title and desc elements |
| aria-label | <svg aria-label="Icon name"> |
Alternative to title element |
| aria-hidden="true" | <svg aria-hidden="true"> |
Decorative SVG |
| focusable="false" | <svg focusable="false"> |
Prevent focus in IE/Edge |
Warning: Never use CSS content property for informative icons - screen readers may not announce
them. Always use proper HTML with ARIA labels or semantic
<title> elements.
| Icon Library | Accessibility Approach | Example |
|---|---|---|
| Font Awesome | Use aria-hidden + text or title |
<i class="fas fa-user" aria-hidden="true"></i><span class="sr-only">User</span>
|
| Material Icons | Add aria-label or adjacent text |
<span class="material-icons" aria-label="delete">delete</span> |
| Heroicons (SVG) | Use aria-hidden on icon, label on parent |
<button aria-label="Menu"><svg aria-hidden="true">...</svg></button>
|
| Emoji | Wrap in role="img" with aria-label |
<span role="img" aria-label="celebration">🎉</span> |
6.6 Multimedia Controls and Interfaces
| Control | Keyboard Shortcut | ARIA Attributes | Required |
|---|---|---|---|
| Play/Pause | Space or K | aria-label="Play" or "Pause" |
✓ Yes |
| Volume | ↑↓ or M (mute) | role="slider", aria-valuenow |
✓ Yes |
| Seek/Scrub | ←→ (5s), J/L (10s) | role="slider", aria-valuetext |
✓ Yes |
| Fullscreen | F | aria-label="Enter fullscreen" |
Recommended |
| Captions Toggle | C | aria-pressed for toggle state |
✓ Yes (if captions available) |
| Playback Speed | Shift+> / Shift+< | aria-label with current speed |
Recommended |
| Audio Descriptions | D | aria-pressed for toggle |
If descriptions available |
Example: Custom accessible video player
<div class="video-player" role="region" aria-label="Video player">
<video id="my-video">
<source src="video.mp4" type="video/mp4">
<track kind="captions" src="captions.vtt" default>
</video>
<div class="controls" role="group" aria-label="Video controls">
<!-- Play/Pause -->
<button
id="play-pause"
aria-label="Play"
class="control-btn">
<svg aria-hidden="true">
<use href="#play-icon"></use>
</svg>
</button>
<!-- Progress bar -->
<div class="progress-wrapper">
<input
type="range"
id="progress"
role="slider"
aria-label="Video progress"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="0"
aria-valuetext="0:00 of 5:30"
min="0"
max="100"
value="0">
<div class="time-display" aria-live="off">
<span id="current-time">0:00</span> /
<span id="duration">5:30</span>
</div>
</div>
<!-- Volume control -->
<button
id="mute"
aria-label="Mute"
aria-pressed="false"
class="control-btn">
<svg aria-hidden="true">
<use href="#volume-icon"></use>
</svg>
</button>
<input
type="range"
id="volume"
role="slider"
aria-label="Volume"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="100"
aria-valuetext="100%"
min="0"
max="100"
value="100">
<!-- Captions toggle -->
<button
id="captions"
aria-label="Captions"
aria-pressed="true"
class="control-btn">
<svg aria-hidden="true">
<use href="#cc-icon"></use>
</svg>
</button>
<!-- Fullscreen -->
<button
id="fullscreen"
aria-label="Enter fullscreen"
class="control-btn">
<svg aria-hidden="true">
<use href="#fullscreen-icon"></use>
</svg>
</button>
</div>
</div>
<script>
class AccessibleVideoPlayer {
constructor(videoElement) {
this.video = videoElement;
this.playPauseBtn = document.getElementById('play-pause');
this.progressBar = document.getElementById('progress');
this.muteBtn = document.getElementById('mute');
this.volumeBar = document.getElementById('volume');
this.captionsBtn = document.getElementById('captions');
this.fullscreenBtn = document.getElementById('fullscreen');
this.setupEventListeners();
this.setupKeyboardShortcuts();
}
setupEventListeners() {
// Play/Pause
this.playPauseBtn.addEventListener('click', () => this.togglePlay());
this.video.addEventListener('play', () => this.updatePlayButton(true));
this.video.addEventListener('pause', () => this.updatePlayButton(false));
// Progress
this.video.addEventListener('timeupdate', () => this.updateProgress());
this.progressBar.addEventListener('input', (e) => this.seek(e.target.value));
// Volume
this.muteBtn.addEventListener('click', () => this.toggleMute());
this.volumeBar.addEventListener('input', (e) => this.setVolume(e.target.value));
// Captions
this.captionsBtn.addEventListener('click', () => this.toggleCaptions());
// Fullscreen
this.fullscreenBtn.addEventListener('click', () => this.toggleFullscreen());
}
setupKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
// Only handle if video player has focus
if (!this.video.closest('.video-player').contains(document.activeElement)) {
return;
}
switch(e.key) {
case ' ':
case 'k':
e.preventDefault();
this.togglePlay();
break;
case 'ArrowLeft':
e.preventDefault();
this.skip(-5);
break;
case 'ArrowRight':
e.preventDefault();
this.skip(5);
break;
case 'ArrowUp':
e.preventDefault();
this.changeVolume(0.1);
break;
case 'ArrowDown':
e.preventDefault();
this.changeVolume(-0.1);
break;
case 'm':
e.preventDefault();
this.toggleMute();
break;
case 'c':
e.preventDefault();
this.toggleCaptions();
break;
case 'f':
e.preventDefault();
this.toggleFullscreen();
break;
}
});
}
togglePlay() {
if (this.video.paused) {
this.video.play();
} else {
this.video.pause();
}
}
updatePlayButton(isPlaying) {
this.playPauseBtn.setAttribute('aria-label', isPlaying ? 'Pause' : 'Play');
}
updateProgress() {
const percent = (this.video.currentTime / this.video.duration) * 100;
this.progressBar.value = percent;
const currentTime = this.formatTime(this.video.currentTime);
const duration = this.formatTime(this.video.duration);
this.progressBar.setAttribute('aria-valuenow', percent);
this.progressBar.setAttribute('aria-valuetext', `${currentTime} of ${duration}`);
document.getElementById('current-time').textContent = currentTime;
document.getElementById('duration').textContent = duration;
}
seek(percent) {
this.video.currentTime = (percent / 100) * this.video.duration;
}
skip(seconds) {
this.video.currentTime += seconds;
}
toggleMute() {
this.video.muted = !this.video.muted;
this.muteBtn.setAttribute('aria-pressed', this.video.muted);
this.muteBtn.setAttribute('aria-label', this.video.muted ? 'Unmute' : 'Mute');
}
setVolume(value) {
this.video.volume = value / 100;
this.volumeBar.setAttribute('aria-valuenow', value);
this.volumeBar.setAttribute('aria-valuetext', `${value}%`);
}
changeVolume(delta) {
const newVolume = Math.max(0, Math.min(1, this.video.volume + delta));
this.video.volume = newVolume;
this.volumeBar.value = newVolume * 100;
this.setVolume(newVolume * 100);
}
toggleCaptions() {
const track = this.video.textTracks[0];
if (track.mode === 'showing') {
track.mode = 'hidden';
this.captionsBtn.setAttribute('aria-pressed', 'false');
} else {
track.mode = 'showing';
this.captionsBtn.setAttribute('aria-pressed', 'true');
}
}
toggleFullscreen() {
if (document.fullscreenElement) {
document.exitFullscreen();
this.fullscreenBtn.setAttribute('aria-label', 'Enter fullscreen');
} else {
this.video.closest('.video-player').requestFullscreen();
this.fullscreenBtn.setAttribute('aria-label', 'Exit fullscreen');
}
}
formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
}
// Initialize player
const player = new AccessibleVideoPlayer(document.getElementById('my-video'));
</script>
| Player Best Practice | Implementation | WCAG Criteria |
|---|---|---|
| Keyboard Accessible | All controls operable via keyboard | 2.1.1 Keyboard |
| Visible Focus | Clear focus indicators on all controls | 2.4.7 Focus Visible |
| Labeled Controls | aria-label on all buttons |
4.1.2 Name, Role, Value |
| State Announcements | Update ARIA attributes dynamically | 4.1.2 Name, Role, Value |
| No Auto-play | User initiates playback | 1.4.2 Audio Control |
| Pause Control | Always provide pause button | 2.2.2 Pause, Stop, Hide |
| Touch Targets ≥44px | Buttons large enough for mobile | 2.5.5 Target Size (AAA) |
Note: Use native
<video controls> when possible - browsers provide
accessible controls by default. Only build custom controls when design absolutely requires it.
Media and Content Accessibility Quick Reference
- All images need
altattribute - usealt=""for decorative images - Describe content/function in alt text, not just "image of..." - keep under 150 characters
- Videos require captions (AA) and audio descriptions (AA) for WCAG compliance
- Provide transcripts for all media - helps SEO and provides searchable alternative
- Use WebVTT format for captions: include dialogue, speaker ID, and sound effects
- Complex charts need: short alt + detailed description + data table alternative
- SVG icons: use
aria-hidden="true"for decorative,role="img"+aria-labelfor informative - Icon-only buttons must have
aria-label- hide icon witharia-hidden - Custom video players need keyboard shortcuts (Space, arrows, M, F, C) and ARIA labels
- Never auto-play media with sound - provide pause/stop controls within 3 clicks/taps
7. Color and Visual Design Guidelines
7.1 Color Contrast Calculations and Tools
| Element Type | WCAG Level AA | WCAG Level AAA | Calculation Method |
|---|---|---|---|
| Normal Text | 4.5:1 minimum | 7:1 minimum | Relative luminance ratio |
| Large Text (>18pt or 14pt bold) | 3:1 minimum | 4.5:1 minimum | Relative luminance ratio |
| UI Components (buttons, form borders) | 3:1 minimum | N/A | Against adjacent colors |
| Graphical Objects (icons, chart elements) | 3:1 minimum | N/A | Against background |
| Focus Indicators WCAG 2.2 | 3:1 minimum | N/A | Against background + unfocused state |
| Disabled Elements | No requirement | No requirement | Best practice: maintain 3:1 |
Example: Color contrast calculations and fixes
<!-- Poor contrast (FAIL) -->
<p style="color: #777; background: #fff;">
This text has 4.47:1 contrast - FAILS AA for normal text
</p>
<!-- Fixed contrast (PASS AA) -->
<p style="color: #595959; background: #fff;">
This text has 7.0:1 contrast - PASSES AA and AAA
</p>
<!-- Large text (18pt+) with lower contrast OK -->
<h2 style="color: #767676; background: #fff; font-size: 24px;">
Large text with 3.01:1 contrast - PASSES AA for large text
</h2>
<!-- Button with sufficient contrast -->
<button style="
background: #0066cc;
color: #fff;
border: 2px solid #004499;">
Text: 8.59:1 (PASS), Border: 5.14:1 against background (PASS)
</button>
<!-- Focus indicator with sufficient contrast -->
<style>
.accessible-link:focus {
outline: 3px solid #0066cc; /* 3.02:1 against white background */
outline-offset: 2px;
}
</style>
<a href="#" class="accessible-link">Accessible focus indicator</a>
<!-- Icon with sufficient contrast -->
<svg style="fill: #595959;" role="img" aria-label="Settings">
<!-- Icon with 7:1 contrast against white background -->
<path d="M..."></path>
</svg>
| Contrast Ratio | Passes For | Visual Example |
|---|---|---|
| 21:1 | Maximum possible (black on white) | Perfect contrast |
| 7:1+ | AAA normal text, AA large text | Excellent readability |
| 4.5:1 - 7:1 | AA normal text | Good readability |
| 3:1 - 4.5:1 | AA large text, UI components | Acceptable for large text/UI |
| <3:1 | Fails all requirements | Poor contrast |
Example: JavaScript contrast checker
// Calculate contrast ratio between two colors
function getContrastRatio(color1, color2) {
const l1 = getRelativeLuminance(color1);
const l2 = getRelativeLuminance(color2);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
// Calculate relative luminance
function getRelativeLuminance(hexColor) {
// Convert hex to RGB
const rgb = hexToRgb(hexColor);
// Convert RGB to sRGB
const srgb = rgb.map(val => {
val = val / 255;
return val <= 0.03928
? val / 12.92
: Math.pow((val + 0.055) / 1.055, 2.4);
});
// Calculate luminance
return 0.2126 * srgb[0] + 0.7152 * srgb[1] + 0.0722 * srgb[2];
}
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? [
parseInt(result[1], 16),
parseInt(result[2], 16),
parseInt(result[3], 16)
] : null;
}
// Check WCAG compliance
function checkWCAG(foreground, background, fontSize, isBold) {
const ratio = getContrastRatio(foreground, background);
const isLargeText = fontSize >= 18 || (fontSize >= 14 && isBold);
const aaThreshold = isLargeText ? 3 : 4.5;
const aaaThreshold = isLargeText ? 4.5 : 7;
return {
ratio: ratio.toFixed(2),
passAA: ratio >= aaThreshold,
passAAA: ratio >= aaaThreshold,
isLargeText
};
}
// Usage
const result = checkWCAG('#595959', '#ffffff', 16, false);
console.log(`Contrast: ${result.ratio}:1`);
console.log(`AA: ${result.passAA ? 'PASS' : 'FAIL'}`);
console.log(`AAA: ${result.passAAA ? 'PASS' : 'FAIL'}`);
| Tool | Type | Features | URL |
|---|---|---|---|
| WebAIM Contrast Checker | Online | Simple, accurate, shows pass/fail | webaim.org/resources/contrastchecker/ |
| Contrast Ratio (Lea Verou) | Online | Real-time, color picker | contrast-ratio.com |
| Colour Contrast Analyser | Desktop app | Eyedropper, simulations | TPGi CCA (free) |
| Chrome DevTools | Browser | Built-in, suggests colors | Chrome DevTools > Elements > Color picker |
| WAVE Extension | Browser | Full page analysis | wave.webaim.org/extension/ |
| Stark Plugin | Design tool | Figma/Sketch integration | getstark.co |
Note: Always test contrast on actual backgrounds. Overlays, gradients, and images can affect
perceived contrast even if colors pass calculation.
7.2 Color Blind Accessibility Patterns
| Color Vision Deficiency | Prevalence | Colors Affected | Design Impact |
|---|---|---|---|
| Protanopia (Red-blind) | ~1% males, 0.01% females | Red appears dark, confused with green | Red/green combinations problematic |
| Deuteranopia (Green-blind) | ~1% males, 0.01% females | Green appears beige, confused with red | Red/green combinations problematic |
| Tritanopia (Blue-blind) | ~0.001% all | Blue/yellow confusion | Blue/yellow combinations problematic |
| Achromatopsia (Total color blindness) | ~0.003% all | No color perception (grayscale) | All color-only indicators fail |
Example: Color blind friendly patterns
<!-- Bad: Color only for status -->
<span style="color: red;">Error</span>
<span style="color: green;">Success</span>
<!-- Good: Color + icon + text -->
<span class="status-error">
<svg aria-hidden="true" class="icon">...error icon...</svg>
Error: File not found
</span>
<span class="status-success">
<svg aria-hidden="true" class="icon">...checkmark...</svg>
Success: File uploaded
</span>
<!-- Bad: Color-only link distinction -->
<p style="color: #000;">
Visit our <a href="#" style="color: #00f;">website</a> for more info.
</p>
<!-- Good: Underline + color -->
<p>
Visit our <a href="#" style="color: #0066cc; text-decoration: underline;">
website
</a> for more info.
</p>
<!-- Good: Form validation with multiple cues -->
<style>
.input-error {
border: 2px solid #d32f2f; /* Color */
border-left: 5px solid #d32f2f; /* Extra visual cue */
background: #ffebee; /* Background tint */
background-image: url('error-icon.svg'); /* Icon */
background-position: right 8px center;
background-repeat: no-repeat;
}
</style>
<input
type="email"
class="input-error"
aria-invalid="true"
aria-describedby="email-error">
<span id="email-error" class="error-text">
⚠ Please enter a valid email address
</span>
<!-- Chart with patterns + colors -->
<style>
.chart-bar-1 {
fill: #0066cc;
stroke-dasharray: none;
}
.chart-bar-2 {
fill: #00897b;
stroke-dasharray: 5,5; /* Stripe pattern */
}
.chart-bar-3 {
fill: #f57c00;
stroke-dasharray: 2,2; /* Dot pattern */
}
</style>
| Design Strategy | Implementation | Example Use |
|---|---|---|
| Use Patterns/Textures | Add stripes, dots, hatching to colored areas | Chart bars, map regions |
| Add Icons/Symbols | Include visual symbols with color | Status indicators (✓, ✗, ⚠) |
| Use Shape Differences | Different shapes for categories | Chart markers (●, ■, ▲) |
| Add Text Labels | Label all colored elements | Chart legends, graph labels |
| Underline Links | Don't rely on color alone for links | Body text links |
| Sufficient Brightness Contrast | Ensure luminance difference even if hue fails | All color combinations |
| Position/Order Cues | Use consistent positioning for status | Traffic lights (red=top, green=bottom) |
Example: Color blind friendly color palettes
<!-- Safe color combinations (work for most CVD types) -->
<style>
:root {
/* Blue-Orange palette (deuteranopia/protanopia safe) */
--color-primary: #0077bb; /* Blue */
--color-secondary: #ee7733; /* Orange */
--color-accent: #009988; /* Teal */
/* High contrast palette */
--color-dark: #332288; /* Dark purple */
--color-medium: #44aa99; /* Teal */
--color-light: #ddcc77; /* Tan */
/* Status colors with sufficient difference */
--color-success: #117733; /* Dark green */
--color-warning: #ddaa33; /* Gold */
--color-error: #cc3311; /* Red */
--color-info: #0077bb; /* Blue */
/* Avoid these combinations */
/* Red + Green (common CVD issue) */
/* Light green + Yellow */
/* Blue + Purple (for some types) */
}
/* Sequential data with brightness steps */
.data-1 { background: #f7fbff; } /* Lightest */
.data-2 { background: #deebf7; }
.data-3 { background: #c6dbef; }
.data-4 { background: #9ecae1; }
.data-5 { background: #6baed6; }
.data-6 { background: #4292c6; }
.data-7 { background: #2171b5; }
.data-8 { background: #08519c; }
.data-9 { background: #08306b; } /* Darkest */
</style>
Warning: Never use color as the only visual means of conveying
information. Always provide additional cues like text, icons, patterns, or positional differences.
7.3 High Contrast Mode Support
| High Contrast Feature | OS/Browser | CSS Detection | Design Impact |
|---|---|---|---|
| Windows High Contrast | Windows 10/11 | @media (prefers-contrast: high) |
Overrides all colors with system palette |
| Increased Contrast | macOS, iOS | @media (prefers-contrast: more) |
Suggests stronger contrast |
| Forced Colors | Windows, browsers | @media (forced-colors: active) |
Limited color palette enforced |
| Inverted Colors | macOS, iOS | @media (inverted-colors: inverted) |
All colors inverted |
Example: High contrast mode support
<!-- Default styles -->
<style>
.button {
background: #0066cc;
color: white;
border: 2px solid #0066cc;
padding: 12px 24px;
}
/* High contrast mode adjustments */
@media (prefers-contrast: high) {
.button {
border-width: 3px; /* Thicker borders */
font-weight: 600; /* Bolder text */
}
/* Ensure focus indicators are extra visible */
.button:focus {
outline: 4px solid;
outline-offset: 4px;
}
}
/* Forced colors mode (Windows High Contrast) */
@media (forced-colors: active) {
.button {
/* Use system colors instead of custom colors */
background: ButtonFace;
color: ButtonText;
border-color: ButtonText;
}
.button:hover {
background: Highlight;
color: HighlightText;
border-color: Highlight;
}
.button:disabled {
color: GrayText;
border-color: GrayText;
}
/* Preserve important visual distinctions */
.icon {
/* Force icon to be visible */
forced-color-adjust: auto;
}
/* Custom focus indicators */
.button:focus {
outline: 2px solid ButtonText;
outline-offset: 2px;
}
}
/* Inverted colors support */
@media (inverted-colors: inverted) {
/* Re-invert images/media so they appear normal */
img, video {
filter: invert(1);
}
}
/* Increased contrast (macOS/iOS) */
@media (prefers-contrast: more) {
body {
/* Slightly increase base contrast */
}
.text-secondary {
/* Make secondary text darker */
color: #444; /* Instead of #666 */
}
}
</style>
| System Color Keyword | Use Case | Example |
|---|---|---|
| ButtonFace | Button background | background: ButtonFace; |
| ButtonText | Button text, borders | color: ButtonText; |
| Canvas | Page background | background: Canvas; |
| CanvasText | Body text | color: CanvasText; |
| LinkText | Hyperlinks | color: LinkText; |
| Highlight | Selected/active background | background: Highlight; |
| HighlightText | Selected text | color: HighlightText; |
| GrayText | Disabled text | color: GrayText; |
| Field | Input background | background: Field; |
| FieldText | Input text | color: FieldText; |
Note: In forced colors mode, custom colors are overridden. Use
forced-color-adjust: none sparingly and only when necessary to preserve critical visual
information.
7.4 Visual Indicators Beyond Color
| Indicator Type | Visual Cue | Use Case | Implementation |
|---|---|---|---|
| Icons | ✓ ✗ ⚠ ℹ ★ | Status, ratings, actions | SVG or icon fonts with ARIA labels |
| Text Labels | "Success", "Error", "Required" | All important states | Explicit text with semantic markup |
| Underlines | Solid, dashed, dotted, double | Links, emphasis, errors | text-decoration |
| Borders | Thickness, style variations | Focus, active state, errors | border with different widths/styles |
| Patterns/Textures | Stripes, dots, crosshatch | Charts, graphs, maps | SVG patterns or CSS gradients |
| Size/Weight | Font size/weight changes | Hierarchy, emphasis | font-size, font-weight |
| Position/Layout | Spatial arrangement | Navigation state, progress | Flexbox/Grid positioning |
| Shape | Circle, square, triangle | Chart markers, bullets | SVG or CSS shapes |
Example: Multi-sensory status indicators
<!-- Status with icon + color + text -->
<div class="alert alert-success">
<svg aria-hidden="true" class="alert-icon">
<use href="#checkmark-icon"></use>
</svg>
<strong>Success:</strong> Your changes have been saved.
</div>
<div class="alert alert-error">
<svg aria-hidden="true" class="alert-icon">
<use href="#error-icon"></use>
</svg>
<strong>Error:</strong> Unable to connect to server.
</div>
<div class="alert alert-warning">
<svg aria-hidden="true" class="alert-icon">
<use href="#warning-icon"></use>
</svg>
<strong>Warning:</strong> Your session will expire in 5 minutes.
</div>
<style>
.alert {
padding: 12px 16px;
border-left: 5px solid; /* Thick left border */
display: flex;
align-items: flex-start;
gap: 12px;
}
.alert-icon {
flex-shrink: 0;
width: 24px;
height: 24px;
}
.alert-success {
background: #e8f5e9;
border-color: #2e7d32;
color: #1b5e20;
}
.alert-success .alert-icon {
fill: #2e7d32;
}
.alert-error {
background: #ffebee;
border-color: #c62828;
color: #b71c1c;
}
.alert-error .alert-icon {
fill: #c62828;
}
.alert-warning {
background: #fff3e0;
border-color: #f57c00;
color: #e65100;
}
.alert-warning .alert-icon {
fill: #f57c00;
}
</style>
<!-- Required field indicators -->
<label for="email">
Email Address
<abbr
title="required"
aria-label="required"
style="color: #d32f2f; text-decoration: none; font-weight: bold;">
*
</abbr>
<span class="sr-only">(required)</span>
</label>
<!-- Link with underline (not just color) -->
<p>
Read our <a href="/privacy" style="
color: #0066cc;
text-decoration: underline;
text-decoration-thickness: 2px;
text-underline-offset: 2px;">privacy policy</a> for details.
</p>
<!-- Progress indicator with multiple cues -->
<div class="progress-steps">
<div class="step step-completed">
<span class="step-number">1</span>
<svg class="step-icon" aria-hidden="true">✓</svg>
<span class="step-label">Personal Info</span>
</div>
<div class="step step-active">
<span class="step-number">2</span>
<span class="step-label">Payment</span>
</div>
<div class="step step-upcoming">
<span class="step-number">3</span>
<span class="step-label">Confirmation</span>
</div>
</div>
Warning: WCAG Success Criterion 1.4.1 requires that color is not used as the only visual means of conveying information. Always combine color with at least one
other visual differentiator.
7.5 CSS Custom Properties for Theming
| Theme Aspect | Custom Property Pattern | Use Case |
|---|---|---|
| Colors | --color-primary, --color-text |
Brand colors, semantic colors |
| Spacing | --space-sm, --space-md |
Consistent spacing scale |
| Typography | --font-size-base, --line-height |
Font sizes, line heights |
| Shadows | --shadow-sm, --shadow-md |
Elevation levels |
| Borders | --border-radius, --border-width |
Consistent borders |
| Transitions | --transition-fast, --transition-slow |
Animation timing |
Example: Accessible theming system with CSS custom properties
<!-- Theme system with light/dark modes -->
<style>
:root {
/* Light theme (default) */
--color-background: #ffffff;
--color-surface: #f5f5f5;
--color-text: #212121;
--color-text-secondary: #757575;
--color-primary: #1976d2;
--color-primary-dark: #1565c0;
--color-error: #d32f2f;
--color-success: #388e3c;
--color-warning: #f57c00;
/* Ensure minimum contrast ratios */
--color-link: #0066cc; /* 4.5:1 on white */
--color-border: #e0e0e0;
/* Typography */
--font-size-base: 16px;
--font-size-lg: 18px;
--font-size-sm: 14px;
--line-height-base: 1.5;
--line-height-heading: 1.2;
/* Spacing scale */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
/* Accessible focus indicator */
--focus-ring: 2px solid var(--color-primary);
--focus-offset: 2px;
}
/* Dark theme */
@media (prefers-color-scheme: dark) {
:root {
--color-background: #121212;
--color-surface: #1e1e1e;
--color-text: #e0e0e0;
--color-text-secondary: #b0b0b0;
--color-primary: #90caf9;
--color-primary-dark: #64b5f6;
--color-error: #ef5350;
--color-success: #66bb6a;
--color-warning: #ffa726;
--color-link: #90caf9; /* 7:1 on dark background */
--color-border: #333333;
}
}
/* High contrast mode adjustments */
@media (prefers-contrast: high) {
:root {
--color-text: #000000;
--color-background: #ffffff;
--color-border: #000000;
--focus-ring: 3px solid;
--focus-offset: 3px;
}
}
/* Manual theme toggle support */
[data-theme="dark"] {
--color-background: #121212;
--color-surface: #1e1e1e;
--color-text: #e0e0e0;
--color-text-secondary: #b0b0b0;
/* ... dark theme colors ... */
}
[data-theme="high-contrast"] {
--color-background: #000000;
--color-text: #ffffff;
--color-primary: #ffff00;
--color-link: #00ffff;
/* Maximum contrast colors */
}
/* Apply theme variables */
body {
background: var(--color-background);
color: var(--color-text);
font-size: var(--font-size-base);
line-height: var(--line-height-base);
}
a {
color: var(--color-link);
text-decoration: underline;
}
button {
background: var(--color-primary);
color: var(--color-background);
border: none;
padding: var(--space-sm) var(--space-md);
}
button:focus-visible {
outline: var(--focus-ring);
outline-offset: var(--focus-offset);
}
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--border-radius, 8px);
padding: var(--space-md);
}
</style>
<!-- Theme toggle -->
<button id="theme-toggle" aria-label="Toggle dark mode">
<svg aria-hidden="true">...moon/sun icon...</svg>
</button>
<script>
const themeToggle = document.getElementById('theme-toggle');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)');
// Get stored theme or use system preference
function getTheme() {
const stored = localStorage.getItem('theme');
if (stored) return stored;
return prefersDark.matches ? 'dark' : 'light';
}
// Apply theme
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
themeToggle.setAttribute('aria-label',
theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'
);
}
// Initialize theme
setTheme(getTheme());
// Toggle theme
themeToggle.addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-theme') || 'light';
setTheme(current === 'dark' ? 'light' : 'dark');
});
// Listen for system preference changes
prefersDark.addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
setTheme(e.matches ? 'dark' : 'light');
}
});
</script>
| Theming Best Practice | Why | Implementation |
|---|---|---|
| Test Contrast in All Themes | Dark mode can fail contrast if not tested | Verify 4.5:1 ratio in light AND dark |
| Respect System Preferences | Users set preferences for a reason | prefers-color-scheme media query |
| Allow Manual Override | Some users want different than system | Theme toggle with localStorage |
| Smooth Transitions | Jarring changes can trigger issues | transition: background 0.3s, color 0.3s |
| Announce Theme Changes | Screen reader users need notification | aria-live announcement |
| Preserve Focus Visibility | Focus must be visible in all themes | Test focus indicators in each theme |
Note: CSS custom properties can't be used in media queries, but can be changed within them.
Define theme variations inside
@media blocks to adapt to user preferences.
Color and Visual Design Quick Reference
- Normal text needs 4.5:1 contrast (AA), 7:1 (AAA); large text needs 3:1 (AA), 4.5:1 (AAA)
- UI components and graphical objects need minimum 3:1 contrast against adjacent colors
- Never use color as the only visual means - add icons, text labels, patterns, or shapes
- Avoid red/green combinations - use blue/orange or add patterns/icons for colorblind users
- Support high contrast mode with system color keywords (ButtonFace, CanvasText, etc.)
- Links must be underlined or have 3:1 contrast with surrounding text
- Test designs with color blindness simulators and actual users
- Use CSS custom properties for themable, maintainable color systems
- Respect
prefers-color-schemeand provide manual theme toggle - Ensure focus indicators have 3:1 contrast in all themes (WCAG 2.2)
8. Typography and Readability Standards
8.1 Font Size and Zoom Support
| WCAG Requirement | Level | Specification | Implementation |
|---|---|---|---|
| Resize Text (1.4.4) | AA | Text can be resized up to 200% without loss of content or functionality | Use relative units (rem, em, %) |
| Reflow (1.4.10) | AA | Content reflows at 320px width without horizontal scrolling | Responsive design with media queries |
| Text Spacing (1.4.12) | AA | No loss of content when spacing is adjusted | Avoid fixed heights, use min-height |
| Minimum Base Size | Best practice | 16px minimum for body text | font-size: 16px or 1rem |
| Zoom Support | AA | Support browser zoom up to 200% | Don't disable user scaling |
Example: Zoom and resize support
<!-- Proper meta viewport (allows user zoom) -->
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- NEVER disable zoom (accessibility violation) -->
<!-- BAD: -->
<meta name="viewport" content="user-scalable=no, maximum-scale=1.0">
<!-- Use relative units for font sizes -->
<style>
/* Base font size on html element */
html {
font-size: 16px; /* Base size for rem calculations */
}
/* Use rem for most text (scales with user preferences) */
body {
font-size: 1rem; /* 16px by default */
line-height: 1.5;
}
h1 {
font-size: 2.5rem; /* 40px at default */
}
h2 {
font-size: 2rem; /* 32px at default */
}
h3 {
font-size: 1.5rem; /* 24px at default */
}
p {
font-size: 1rem; /* 16px at default */
}
small {
font-size: 0.875rem; /* 14px at default */
}
/* Use em for component-relative sizing */
.button {
font-size: 1rem;
padding: 0.5em 1em; /* Scales with button font size */
}
.button--large {
font-size: 1.25rem;
padding: 0.5em 1em; /* Proportionally larger */
}
/* Responsive font sizing */
@media (max-width: 768px) {
html {
font-size: 14px; /* Smaller base on mobile */
}
}
@media (min-width: 1200px) {
html {
font-size: 18px; /* Larger base on large screens */
}
}
/* Fluid typography (clamps between min and max) */
h1 {
font-size: clamp(2rem, 5vw, 3rem);
}
/* Support text spacing adjustments (WCAG 1.4.12) */
* {
/* Don't set fixed heights that break with spacing changes */
/* Use min-height instead of height when possible */
}
.card {
min-height: 200px; /* Not height: 200px */
padding: 1rem;
}
/* Text should reflow without horizontal scroll at 320px */
.content {
max-width: 100%;
overflow-wrap: break-word;
word-wrap: break-word;
}
</style>
| Unit Type | Description | Use Case | Accessibility |
|---|---|---|---|
| rem | Relative to root font-size | Most text, spacing, layout | ✓ Respects user preferences |
| em | Relative to parent font-size | Component-scoped sizing, padding | ✓ Respects user preferences |
| % (percentage) | Relative to parent | Widths, responsive layouts | ✓ Flexible |
| px (pixels) | Absolute unit | Borders, shadows, small details | ⚠ Doesn't scale with zoom |
| vw/vh | Viewport percentage | Full-screen sections, fluid type | ⚠ Can cause reflow issues |
| clamp() | Min, preferred, max | Fluid typography with bounds | ✓ Responsive + bounded |
Warning: Never use
maximum-scale=1.0 or user-scalable=no in viewport
meta tag. This prevents users from zooming and fails WCAG 1.4.4.
8.2 Line Height and Spacing Guidelines
| Spacing Property | WCAG 1.4.12 Minimum | Recommended | CSS Property |
|---|---|---|---|
| Line Height | 1.5× font size (paragraphs) | 1.5-1.8 | line-height: 1.5 |
| Paragraph Spacing | 2× font size | 1.5-2rem | margin-bottom: 1.5rem |
| Letter Spacing | 0.12× font size | normal to 0.05em | letter-spacing: 0.02em |
| Word Spacing | 0.16× font size | normal to 0.1em | word-spacing: 0.05em |
Example: Accessible spacing implementation
<style>
/* Base typography with accessible spacing */
body {
font-size: 1rem;
line-height: 1.6; /* Exceeds 1.5 minimum */
letter-spacing: 0.01em;
}
/* Paragraph spacing */
p {
margin-bottom: 1.5rem; /* 2× font size minimum */
line-height: 1.6;
}
/* Heading spacing */
h1, h2, h3, h4, h5, h6 {
line-height: 1.3; /* Tighter for headings OK */
margin-top: 2rem;
margin-bottom: 1rem;
}
/* Lists with adequate spacing */
ul, ol {
margin-bottom: 1.5rem;
}
li {
margin-bottom: 0.5rem;
line-height: 1.6;
}
/* Dense text (tables, code) can be tighter */
table {
line-height: 1.4; /* Still readable */
}
code, pre {
line-height: 1.5;
letter-spacing: 0;
}
/* Support user text spacing overrides (WCAG 1.4.12) */
/* Users may apply: */
* {
/* line-height: 1.5 */
/* letter-spacing: 0.12em */
/* word-spacing: 0.16em */
/* paragraph spacing: 2em */
}
/* Ensure content doesn't break when spacing is increased */
.card {
min-height: fit-content; /* Not fixed height */
padding: 1rem;
}
.button {
padding: 0.75em 1.5em;
min-height: 2.5em; /* Not height: 40px */
}
/* Long-form content optimized for readability */
article p {
max-width: 65ch; /* 65-75 characters per line */
line-height: 1.7;
margin-bottom: 1.5rem;
}
/* Tight spacing exceptions (sparingly) */
.compact-list {
line-height: 1.4;
}
.compact-list li {
margin-bottom: 0.25rem;
}
</style>
<!-- Text spacing adjustment test -->
<style>
/* Apply maximum WCAG 1.4.12 spacing to test */
.wcag-spacing-test * {
line-height: 1.5 !important;
letter-spacing: 0.12em !important;
word-spacing: 0.16em !important;
}
.wcag-spacing-test p {
margin-bottom: 2em !important;
}
/* Content should remain readable and not clip */
</style>
| Content Type | Line Height | Max Line Length | Rationale |
|---|---|---|---|
| Body Text | 1.5-1.8 | 65-75 characters | Optimal reading comfort |
| Headings | 1.2-1.4 | 40-50 characters | Tighter for visual hierarchy |
| Captions | 1.4-1.6 | 55-65 characters | Small text needs more spacing |
| Code Blocks | 1.5-1.6 | 80-120 characters | Monospace allows longer lines |
| Tables | 1.4-1.5 | Varies by column | Denser spacing acceptable |
| UI Labels | 1.3-1.5 | N/A | Compact for interface elements |
Note: The 65-75 character line length guideline is based on readability research. Use
max-width: 65ch to implement this in CSS (1ch = width of the "0" character in the current font).
8.3 Text Scaling and Responsive Typography
| Technique | Method | Benefits | Considerations |
|---|---|---|---|
| Fluid Typography | clamp(min, preferred, max) |
Smooth scaling between breakpoints | Ensure minimum meets WCAG |
| Viewport-Based | font-size: calc(1rem + 1vw) |
Scales with viewport | Can be too small/large at extremes |
| Media Query Steps | Different sizes per breakpoint | Precise control at each size | Sudden jumps between breakpoints |
| Container Queries | Size based on container width | Component-level responsiveness | Newer browser support |
| User Preference | Respect browser font-size setting | User control, accessibility | Must use relative units |
Example: Responsive typography strategies
<style>
/* Strategy 1: Fluid typography with clamp() */
h1 {
font-size: clamp(1.75rem, 4vw + 1rem, 3.5rem);
/* Min: 28px, Scales with viewport, Max: 56px */
}
h2 {
font-size: clamp(1.5rem, 3vw + 0.5rem, 2.5rem);
}
p {
font-size: clamp(1rem, 0.5vw + 0.875rem, 1.125rem);
}
/* Strategy 2: Media query steps */
body {
font-size: 16px;
}
@media (max-width: 480px) {
body { font-size: 14px; }
h1 { font-size: 1.75rem; }
}
@media (min-width: 481px) and (max-width: 768px) {
body { font-size: 15px; }
h1 { font-size: 2rem; }
}
@media (min-width: 769px) and (max-width: 1024px) {
body { font-size: 16px; }
h1 { font-size: 2.5rem; }
}
@media (min-width: 1025px) {
body { font-size: 18px; }
h1 { font-size: 3rem; }
}
/* Strategy 3: Viewport-based with bounds */
h1 {
font-size: calc(1.5rem + 2vw);
max-font-size: 4rem;
min-font-size: 2rem; /* Note: not a real property, use clamp */
}
/* Better: using clamp for the same effect */
h1 {
font-size: clamp(2rem, 1.5rem + 2vw, 4rem);
}
/* Strategy 4: Container queries (modern browsers) */
@container (min-width: 400px) {
.card h2 {
font-size: 1.5rem;
}
}
@container (min-width: 600px) {
.card h2 {
font-size: 2rem;
}
}
/* Strategy 5: Modular scale */
:root {
--ratio: 1.25; /* Major third */
--base-size: 1rem;
--text-xs: calc(var(--base-size) / var(--ratio) / var(--ratio));
--text-sm: calc(var(--base-size) / var(--ratio));
--text-base: var(--base-size);
--text-lg: calc(var(--base-size) * var(--ratio));
--text-xl: calc(var(--base-size) * var(--ratio) * var(--ratio));
--text-2xl: calc(var(--base-size) * var(--ratio) * var(--ratio) * var(--ratio));
}
small { font-size: var(--text-sm); }
body { font-size: var(--text-base); }
h3 { font-size: var(--text-lg); }
h2 { font-size: var(--text-xl); }
h1 { font-size: var(--text-2xl); }
/* Responsive adjustments to base size */
@media (max-width: 768px) {
:root {
--base-size: 0.875rem; /* 14px */
}
}
@media (min-width: 1200px) {
:root {
--base-size: 1.125rem; /* 18px */
}
}
/* Ensure minimum sizes at all breakpoints */
@media (max-width: 480px) {
body {
font-size: max(16px, 1rem); /* Never below 16px */
}
}
</style>
Example: Accessible responsive typography system
<style>
/* Complete responsive type system */
:root {
/* Base sizes */
--font-size-min: 0.875rem; /* 14px */
--font-size-base: 1rem; /* 16px */
--font-size-max: 1.125rem; /* 18px */
/* Fluid size between 320px and 1200px viewport */
--fluid-min-width: 320;
--fluid-max-width: 1200;
/* Calculate fluid type */
--fluid-bp: calc(
(100vw - var(--fluid-min-width) * 1px) /
(var(--fluid-max-width) - var(--fluid-min-width))
);
}
/* Fluid body text */
body {
font-size: calc(
var(--font-size-min) +
(var(--font-size-max) - var(--font-size-min)) * var(--fluid-bp)
);
line-height: 1.6;
}
/* Simplified with clamp() */
body {
font-size: clamp(
var(--font-size-min),
0.875rem + 0.25vw,
var(--font-size-max)
);
}
/* Heading scale */
h1 { font-size: clamp(2rem, 1.5rem + 2vw, 3.5rem); }
h2 { font-size: clamp(1.75rem, 1.25rem + 1.5vw, 2.75rem); }
h3 { font-size: clamp(1.5rem, 1rem + 1vw, 2rem); }
h4 { font-size: clamp(1.25rem, 0.875rem + 0.75vw, 1.5rem); }
h5 { font-size: clamp(1.125rem, 0.875rem + 0.5vw, 1.25rem); }
h6 { font-size: clamp(1rem, 0.875rem + 0.25vw, 1.125rem); }
/* Ensure accessibility: never go below minimums */
@supports not (font-size: clamp(1rem, 1vw, 2rem)) {
/* Fallback for older browsers */
body { font-size: 16px; }
h1 { font-size: 2.5rem; }
h2 { font-size: 2rem; }
/* etc... */
}
</style>
Warning: When using viewport units (vw, vh), always use
clamp() or
max() to ensure text doesn't become too small on narrow viewports, which would fail WCAG 1.4.4.
8.4 Reading Flow and Text Layout
| Layout Principle | Specification | Benefits | Implementation |
|---|---|---|---|
| Line Length | 50-75 characters optimal | Reduces eye fatigue, improves comprehension | max-width: 65ch |
| Text Alignment | Left-aligned for LTR languages | Consistent left edge aids tracking | text-align: left |
| Justified Text | Avoid or use with hyphenation | Prevents uneven spacing | hyphens: auto if justified |
| Column Width | Single column on mobile, multi on desktop | Appropriate line length at all sizes | columns or grid layout |
| Orphans/Widows | Prevent single lines at breaks | Visual continuity | orphans: 2; widows: 2 |
| Vertical Rhythm | Consistent spacing between elements | Visual harmony, scanability | Baseline grid or spacing scale |
Example: Optimal reading flow layout
<style>
/* Article layout optimized for readability */
article {
max-width: 65ch; /* 50-75 character line length */
margin: 0 auto;
padding: 2rem 1rem;
}
/* Long-form text styling */
article p {
text-align: left; /* Never center long text */
line-height: 1.7;
margin-bottom: 1.5rem;
/* Prevent orphans and widows */
orphans: 2;
widows: 2;
/* Better word breaking */
overflow-wrap: break-word;
word-wrap: break-word;
hyphens: auto; /* Enable hyphenation */
}
/* Avoid justified text or improve it */
.justified {
text-align: justify;
hyphens: auto; /* Required for even spacing */
text-justify: inter-word; /* Better spacing algorithm */
}
/* Multi-column layout (use carefully) */
@media (min-width: 900px) {
.multi-column {
column-count: 2;
column-gap: 3rem;
column-rule: 1px solid #e0e0e0;
}
/* Prevent headings from breaking across columns */
.multi-column h2,
.multi-column h3 {
break-after: avoid;
column-span: all; /* Heading spans all columns */
}
/* Prevent images from breaking */
.multi-column img {
break-inside: avoid;
}
}
/* Responsive line length */
.content {
width: 100%;
max-width: 65ch;
}
@media (max-width: 768px) {
.content {
max-width: 100%; /* Full width on mobile */
padding: 0 1rem;
}
}
/* Vertical rhythm system */
:root {
--line-height: 1.6;
--rhythm: calc(1rem * var(--line-height));
}
h1, h2, h3, p, ul, ol {
margin-bottom: var(--rhythm);
}
/* Lists with proper spacing */
ul, ol {
padding-left: 1.5rem;
}
li {
margin-bottom: 0.5rem;
line-height: 1.6;
}
/* Pull quotes or callouts */
.callout {
max-width: 45ch; /* Shorter for emphasis */
margin: 2rem auto;
padding: 1.5rem;
font-size: 1.125rem;
border-left: 4px solid #0066cc;
}
/* Code blocks with scrolling instead of wrapping */
pre {
max-width: 100%;
overflow-x: auto;
padding: 1rem;
line-height: 1.5;
}
/* Prevent very long words from breaking layout */
.user-content {
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word; /* Breaks long words */
}
/* Direction support for RTL languages */
[dir="rtl"] {
text-align: right;
}
[dir="rtl"] ul,
[dir="rtl"] ol {
padding-left: 0;
padding-right: 1.5rem;
}
</style>
<article lang="en">
<h1>Article Title</h1>
<p>
This paragraph has an optimal line length of around 65 characters,
which research shows provides the best reading experience. The text
is left-aligned with adequate line height for easy tracking.
</p>
<p>
Long form content should never be centered or right-aligned as this
makes it difficult for readers to find the start of each new line.
</p>
</article>
Note: Centered or right-aligned text is acceptable for short content like headings, captions,
or UI elements, but long-form body text should always be left-aligned (or right-aligned for RTL languages).
8.5 Font Selection for Accessibility
| Font Characteristic | Accessible Choice | Avoid | Why |
|---|---|---|---|
| Letter Differentiation | Clear distinction between I, l, 1, O, 0 | Fonts where letters look identical | Reduces confusion for dyslexic users |
| X-Height | Larger x-height (relative to cap height) | Very small x-height | Improves readability at small sizes |
| Weight Variation | Multiple weights available | Single weight only | Needed for hierarchy and emphasis |
| Character Spacing | Slightly open spacing | Very tight or condensed | Easier to distinguish letters |
| Decorative Features | Minimal for body text | Excessive serifs, swashes, ornaments | Can distract or confuse readers |
| Font Style | Sans-serif for UI, serif OK for long text | Script, handwriting, novelty fonts | Novelty fonts harder to read |
Example: Accessible font selection
<style>
/* Accessible font stacks */
/* System font stack (no web fonts needed, fast) */
body {
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial,
sans-serif;
}
/* Readable serif stack for long-form content */
article {
font-family:
"Iowan Old Style",
"Palatino Linotype",
"URW Palladio L",
Georgia,
serif;
}
/* Monospace stack for code */
code, pre {
font-family:
"SF Mono",
Monaco,
"Cascadia Code",
"Roboto Mono",
Consolas,
"Courier New",
monospace;
}
/* Web fonts with careful selection */
@font-face {
font-family: 'CustomFont';
src: url('font.woff2') format('woff2');
font-display: swap; /* Show fallback during load */
font-weight: 400;
font-style: normal;
}
/* Use variable fonts for performance */
@font-face {
font-family: 'InterVariable';
src: url('inter-variable.woff2') format('woff2');
font-weight: 100 900; /* Supports all weights */
font-display: swap;
}
/* Accessible fonts recommended: */
/* - Inter (excellent readability) */
/* - Atkinson Hyperlegible (designed for low vision) */
/* - Open Sans, Roboto (good alternatives) */
/* - Lexend (dyslexia-friendly) */
/* Font adjustments for accessibility */
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
/* Never use ultra-thin weights for body text */
.thin-text {
font-weight: 100; /* BAD for accessibility */
}
.readable-text {
font-weight: 400; /* GOOD: normal weight */
}
/* Italic text - use sparingly */
em, i {
font-style: italic;
/* Some fonts have poor italics - consider checking */
}
/* All caps - harder to read, use sparingly */
.uppercase {
text-transform: uppercase;
letter-spacing: 0.05em; /* Increase spacing */
font-size: 0.875em; /* Slightly smaller often better */
}
/* Font size adjustments */
small {
font-size: 0.875rem; /* Don't go below 14px equivalent */
}
/* Ensure minimum legible sizes */
@media (max-width: 768px) {
small {
font-size: max(0.875rem, 14px);
}
}
</style>
<!-- Example: Dyslexia-friendly font -->
<link href="https://fonts.googleapis.com/css2?family=Lexend:wght@400;600&display=swap" rel="stylesheet">
<style>
.dyslexia-friendly {
font-family: 'Lexend', sans-serif;
font-size: 1.125rem; /* Slightly larger */
line-height: 1.8; /* More spacing */
letter-spacing: 0.05em;
word-spacing: 0.1em;
}
</style>
| Font Family | Type | Accessibility Features | Best Use |
|---|---|---|---|
| Atkinson Hyperlegible | Sans-serif | Designed for low vision, high differentiation | UI, body text, low vision users |
| Inter | Sans-serif | Tall x-height, open apertures | UI, screens, body text |
| Lexend | Sans-serif | Designed for dyslexia | Body text, educational content |
| Open Dyslexic | Sans-serif | Weighted bottoms, unique shapes | Dyslexic users (optional) |
| Verdana | Sans-serif | Large x-height, wide spacing | Small sizes, screens |
| Georgia | Serif | Large characters, screen-optimized | Long-form reading |
| Comic Sans | Sans-serif | Letter differentiation (controversial) | Dyslexic users (user preference) |
Warning: Avoid using font weights below 400 for body text, and
never use decorative or script fonts for important content. They can be difficult or impossible for some users
to read.
Note: Consider providing a font switcher for users with dyslexia or low vision. Allow users to
choose between standard fonts and specialized alternatives like Lexend or OpenDyslexic.
Typography and Readability Quick Reference
- Use relative units (rem, em) for all font sizes - never disable zoom with viewport meta
- Minimum 16px base font size; text must be resizable to 200% without loss of content
- Line height minimum 1.5 for body text; paragraph spacing minimum 2× font size (WCAG 1.4.12)
- Optimal line length: 50-75 characters (use
max-width: 65ch) - Left-align body text (right-align for RTL); avoid justified text unless using hyphenation
- Use
clamp()for fluid typography with minimum/maximum bounds - Choose fonts with clear letter differentiation (I/l/1, O/0) and adequate x-height
- Provide font weight variation (400 minimum for body text; avoid ultra-thin weights)
- Test spacing with WCAG 1.4.12 adjustments: content must not clip or overlap
- Consider specialized fonts (Atkinson Hyperlegible, Lexend) for improved accessibility
9. Mobile and Touch Accessibility
9.1 Touch Target Size and Spacing
| Target Type | Minimum Size (WCAG 2.2) | Recommended Size | Minimum Spacing |
|---|---|---|---|
| Primary actions (buttons, links) | 24×24 CSS pixels WCAG 2.5.8 AA | 44×44 CSS pixels (iOS), 48×48 dp (Android) | 8px between targets |
| Critical interactive elements | 24×24 CSS pixels | 48×48 CSS pixels minimum | 12px spacing preferred |
| Text input fields | 24px height minimum | 44-48px height (includes padding) | 16px vertical spacing |
| Icon-only buttons | 24×24 CSS pixels | 48×48 CSS pixels (includes padding) | 8px on all sides |
| Inline text links | Exception: sentence flow allowed | Increase line-height to 1.5+ for easier tapping | Underline + adequate spacing |
| Toggle switches | 44px width × 24px height minimum | 51px × 31px (iOS standard) | 12px spacing |
| Checkbox/Radio buttons | 24×24 CSS pixels | 32×32 CSS pixels (visual + padding) | 16px spacing between options |
Example: Accessible touch target with proper sizing
<!-- CSS for touch targets -->
.touch-target {
min-width: 48px;
min-height: 48px;
padding: 12px;
margin: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
}
<!-- Small icon with extended touch area -->
<button class="touch-target" aria-label="Delete item">
<svg width="16" height="16" aria-hidden="true">
<use href="#icon-delete"></use>
</svg>
</button>
Touch Target Exceptions: WCAG 2.5.8 allows exceptions for inline text links, user agent
controls (native browser UI), and when target size is essential to the information being conveyed.
9.2 Mobile Screen Reader Support
| Platform | Screen Reader | Key Gestures | Testing Focus |
|---|---|---|---|
| iOS | VoiceOver |
Swipe right/left: Navigate Double-tap: Activate Three-finger swipe: Scroll Rotor: Quick navigation |
Heading navigation, landmark navigation, custom actions |
| Android | TalkBack |
Swipe right/left: Navigate Double-tap: Activate Two-finger swipe: Scroll Local context menu: Actions |
Reading order, custom actions, state announcements |
| Mobile Safari | VoiceOver (iOS) | Two-finger Z: Skip web content Rotor + swipe: Jump by element type |
Web vs native behavior, form controls, ARIA support |
| Chrome/Firefox (Android) | TalkBack | Volume key shortcuts TalkBack menu: Advanced controls |
Web navigation, custom widget support |
Example: Mobile-optimized ARIA labels and hints
<!-- Concise labels for mobile screen readers -->
<button
aria-label="Add to cart"
aria-describedby="product-price">
<span aria-hidden="true">+</span>
</button>
<span id="product-price" class="sr-only">$29.99</span>
<!-- Custom actions for swipe gestures (iOS) -->
<div role="article"
aria-label="Product: Wireless Headphones"
data-voiceover-actions="delete,share,favorite">
<!-- Content -->
</div>
Mobile Screen Reader Pitfalls: Avoid excessive ARIA descriptions (verbose announcements),
ensure touch targets don't overlap (causes navigation confusion), and test swipe-to-delete patterns with actual
screen readers.
9.3 Gesture Accessibility Alternatives
| Gesture Pattern | WCAG Requirement | Alternative Method | Implementation |
|---|---|---|---|
| Swipe to delete | Provide visible delete button 2.5.1 | Edit mode with delete buttons, long-press menu | Show delete button on item focus or in action menu |
| Pinch to zoom | Alternative zoom controls required | +/- buttons, zoom slider, double-tap zoom | Provide visible zoom controls or use native browser zoom |
| Drag and drop | Pointer-independent alternative 2.5.7 | Move up/down buttons, cut/paste, select-and-place | Provide button-based reordering mechanism |
| Multi-touch gestures | Single-pointer alternative required | Sequential taps, menu options, mode switches | Toolbar buttons or settings menu for multi-touch actions |
| Path-based gestures (draw shape) | Alternative input method required 2.5.1 | Button selection, voice input, keyboard shortcuts | Provide pattern picker or command palette |
| Long press | Not required but recommended alternative | Menu button, right-click, dedicated action button | Show "..." menu button for context actions |
| Swipe navigation (carousel) | Previous/Next buttons required | Arrow buttons, pagination dots, keyboard arrows | Visible navigation controls always present |
Example: Accessible drag-and-drop with alternatives
<!-- List item with both drag and button-based reordering -->
<li role="listitem" aria-label="Task: Review pull request"
draggable="true">
<span class="drag-handle" aria-label="Drag to reorder">⋮⋮</span>
<span>Review pull request</span>
<!-- Alternative to dragging -->
<div class="reorder-controls" role="group" aria-label="Reorder controls">
<button aria-label="Move up">↑</button>
<button aria-label="Move down">↓</button>
</div>
</li>
Gesture Best Practices: Always provide at least one pointer-independent alternative (WCAG
2.5.1). Ensure alternatives are discoverable without requiring gesture knowledge. Test with assistive
technologies like switch controls and voice access.
9.4 Orientation and Zoom Handling
| Feature | WCAG Requirement | Implementation | Exceptions |
|---|---|---|---|
| Orientation support | Content not restricted to single orientation 1.3.4 AA | Support both portrait and landscape; use CSS media queries | Essential: piano app, bank check, projector slides |
| Zoom and scaling | Allow up to 200% zoom without loss of content 1.4.4 AA | <meta name="viewport" content="width=device-width, initial-scale=1"> |
Never use user-scalable=no or maximum-scale=1 |
| Text resize (400%) | Text scales to 400% without horizontal scroll 1.4.10 AA | Use responsive design, relative units (rem, em), fluid layouts | Images of text, captions (but must reflow on zoom) |
| Reflow at 320px | Content reflows without 2D scrolling at 320 CSS pixels 1.4.10 AA | Mobile-first design, avoid fixed widths, use flexbox/grid | Data tables, maps, diagrams, toolbars (horizontal scroll OK) |
| Orientation change | Content must not be lost on rotation | Test form data persistence, modal positions, focus retention | None - always preserve state |
Example: Proper viewport and orientation CSS
<!-- Correct viewport meta tag -->
<meta name="viewport"
content="width=device-width, initial-scale=1, minimum-scale=1">
<!-- NEVER do this (blocks zoom) -->
<!-- <meta name="viewport" content="maximum-scale=1, user-scalable=no"> -->
<!-- CSS for orientation support -->
@media (orientation: portrait) {
.toolbar {
flex-direction: column;
}
}
@media (orientation: landscape) {
.toolbar {
flex-direction: row;
}
}
/* Responsive font sizing with safe minimums */
body {
font-size: clamp(16px, 1rem + 0.5vw, 20px);
}
Common Zoom Mistakes: Disabling zoom via viewport meta, using
position: fixed with
pixel widths (causes overflow at zoom), using viewport units (vw/vh) for font sizes without clamp(), and
forgetting to test horizontal scrolling at 200%+ zoom levels.
9.5 Mobile Focus Management
| Focus Scenario | Mobile Consideration | Implementation | Testing Method |
|---|---|---|---|
| Virtual keyboard appearance | Scroll input into view, maintain focus visibility | element.scrollIntoView({ behavior: 'smooth', block: 'center' }) |
Test with on-screen keyboard on iOS/Android |
| Modal dialogs | Trap focus, prevent body scroll, return focus on close | Use inert attribute on background or focus trap library |
Test with screen reader swipe navigation |
| Dynamic content insertion | Move focus to new content or announce with ARIA live | Set focus to first interactive element or heading in new content | Verify screen reader announces insertion |
| Touch vs keyboard focus | Different focus indicators for touch and keyboard | Use :focus-visible to show focus only for keyboard |
Test with external keyboard on mobile device |
| Focus order in landscape | Maintain logical reading order across orientations | Use flexbox order or grid with careful tabindex management | Rotate device and verify tab order consistency |
| Bottom sheets / drawers | Focus first focusable element when opened | Programmatic focus on open, restore on close | Screen reader navigation and keyboard tab order |
Example: Mobile keyboard and focus management
// Handle virtual keyboard appearance
const input = document.getElementById('mobile-search');
input.addEventListener('focus', () => {
// Scroll element into view with padding for keyboard
setTimeout(() => {
input.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest'
});
}, 300); // Delay for keyboard animation
});
// Mobile modal with focus trap
class MobileModal {
open() {
this.previousFocus = document.activeElement;
this.modal.showModal(); // Native focus trap
// Focus first interactive element
const firstFocusable = this.modal.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
firstFocusable?.focus();
// Prevent body scroll on mobile
document.body.style.overflow = 'hidden';
}
close() {
this.modal.close();
document.body.style.overflow = '';
this.previousFocus?.focus(); // Restore focus
}
}
Mobile Focus Tips: Use
:focus-visible to show focus rings only for keyboard
navigation (not touch). Test with external keyboard on tablets. Ensure focus indicators are at least 3:1
contrast ratio against adjacent colors (WCAG 2.4.13).
9.6 Voice Control Integration
| Voice Control Feature | Platform | Accessibility Requirement | Implementation |
|---|---|---|---|
| Voice Access (Android) | Android 11+ | Visible labels match accessible names | Ensure aria-label matches visible text or use aria-labelledby |
| Voice Control (iOS) | iOS 13+ | Interactive elements have accessible names | All tappable elements must have text or aria-label |
| Click by number | Both platforms | Elements numbered for voice activation | Proper semantic markup ensures numbering works |
| Click by name | Both platforms | Visible label text must be in accessible name 2.5.3 | Start aria-label with visible text: "Delete product X" |
| Show labels/numbers | Overlay mode | All interactive elements discoverable | Avoid pointer-events: none on focusable elements |
| Custom commands | Platform-specific | Document available voice commands | Provide help text for complex interactions |
Example: Voice control accessible button labeling
<!-- CORRECT: Visible label matches accessible name -->
<button aria-label="Delete product: Wireless Mouse">
Delete
</button>
<!-- Voice command: "Click Delete" works! -->
<!-- INCORRECT: Accessible name doesn't include visible text -->
<button aria-label="Remove item from cart">
Delete
</button>
<!-- Voice command: "Click Delete" FAILS -->
<!-- BEST: Use aria-labelledby for compound labels -->
<button aria-labelledby="delete-label product-name">
<span id="delete-label">Delete</span>
</button>
<span id="product-name" class="sr-only">Wireless Mouse</span>
Voice Control Label Rule (WCAG 2.5.3): When visible text labels exist, the accessible name MUST
start with the visible text. Example: Button shows "Search" → aria-label can be "Search products" but NOT "Find
products". This ensures voice commands like "Click Search" work reliably.
Mobile and Touch Accessibility Quick Reference
- Touch Targets: Minimum 24×24px (WCAG 2.2), recommended 48×48px with 8px spacing
- Screen Readers: Test with VoiceOver (iOS) and TalkBack (Android); keep labels concise
- Gestures: Provide pointer-independent alternatives for swipe, drag, pinch (WCAG 2.5.1, 2.5.7)
- Orientation: Support both portrait/landscape; never restrict to single orientation (WCAG 1.3.4)
- Zoom: Allow 200% zoom minimum; never use
user-scalable=noormaximum-scale=1 - Reflow: Content must reflow at 320px width without horizontal scrolling (WCAG 1.4.10)
- Focus: Use
:focus-visiblefor keyboard-only indicators; manage virtual keyboard with scrollIntoView - Voice Control: Visible labels must start accessible names (WCAG 2.5.3); test "Click [label]" commands
- Testing: Use real devices with screen readers, external keyboards, voice control, and switch access
- Key Tools: iOS VoiceOver, Android TalkBack, Voice Access, Chrome DevTools device mode, Xcode Accessibility Inspector
10. Modern CSS Accessibility Features
10.1 prefers-reduced-motion Implementation
| Motion Type | Default Behavior | Reduced Motion Alternative | WCAG Reference |
|---|---|---|---|
| Page transitions | Slide/fade animations (200-400ms) | Instant or crossfade (50ms) | 2.3.3 AAA (Animation from Interactions) |
| Scroll-triggered animations | Elements slide/fade in on scroll | Elements appear immediately (opacity: 1) | 2.2.2 AA (Pause, Stop, Hide) |
| Hover effects | Transform scale/rotate | Color/opacity change only | User preference respect |
| Loading spinners | Rotating animation | Pulsing opacity or static icon with ARIA | Reduce vestibular triggers |
| Parallax effects | Multi-layer scrolling | Disable completely (single-layer scroll) | 2.3.3 AAA |
| Auto-playing video | Background video loops | Static image or paused video | 2.2.2 AA (5-second rule) |
| Carousel auto-advance | Slides change every 5s | Disable auto-advance completely | 2.2.2 AA |
Example: Comprehensive prefers-reduced-motion implementation
/* Default: animations enabled */
.slide-in {
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* Reduced motion: instant appearance */
@media (prefers-reduced-motion: reduce) {
.slide-in {
animation: none;
/* Maintain end state without animation */
transform: translateY(0);
opacity: 1;
}
/* Reduce all animations and transitions */
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Safe alternative: crossfade instead of slide */
@media (prefers-reduced-motion: reduce) {
.modal {
animation: fadeIn 0.15s ease-in;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
}
Critical: Never use
animation: none without preserving the end state. Users with
vestibular disorders can experience nausea, dizziness, and migraines from parallax, zoom, and rotation effects.
Always test with OS setting enabled.
10.2 prefers-color-scheme Support
| Feature | Light Mode | Dark Mode | Contrast Requirement |
|---|---|---|---|
| Background color | #ffffff or light neutrals | #000000, #121212, #1e1e1e | Maintain 4.5:1 text contrast |
| Text color | #000000, #333333, #1a1a1a | #ffffff, #e0e0e0, #f5f5f5 | 4.5:1 for body, 3:1 for large text |
| Link color | #0066cc, #1a73e8 | #66b3ff, #8ab4f8 | 4.5:1 on background + underline |
| Border/divider | #e0e0e0, #d0d0d0 | #404040, #505050 | 3:1 against adjacent colors |
| Focus indicator | #0066cc (blue) | #66b3ff (lighter blue) | 3:1 minimum (WCAG 2.4.13) |
| Code blocks | Light syntax highlighting | Dark syntax highlighting | Each token needs 4.5:1 contrast |
| Images/logos | Default version | Inverted or alternate version | Use picture element or CSS filter |
Example: Dark mode with CSS custom properties
:root {
/* Light mode (default) */
--bg-primary: #ffffff;
--bg-secondary: #f5f5f5;
--text-primary: #1a1a1a;
--text-secondary: #666666;
--accent: #0066cc;
--border: #e0e0e0;
--focus-ring: #0066cc;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #1e1e1e;
--bg-secondary: #2d2d2d;
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
--accent: #66b3ff;
--border: #404040;
--focus-ring: #66b3ff;
}
/* Adjust images for dark mode */
img:not([src*=".svg"]) {
filter: brightness(0.9);
}
/* Invert logos that need it */
.logo {
filter: invert(1);
}
}
body {
background: var(--bg-primary);
color: var(--text-primary);
}
a {
color: var(--accent);
text-decoration: underline;
}
:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
Dark Mode Best Practices: Use CSS custom properties for easy theme switching. Test contrast
ratios in both modes. Provide manual toggle that persists user preference. Avoid pure black (#000) backgrounds
(use #121212 or #1e1e1e for better readability).
10.3 prefers-contrast Handling
| Preference Value | User Need | Design Adjustment | Browser Support |
|---|---|---|---|
| no-preference | Standard contrast (default) | Use standard design system colors | All modern browsers |
| more | High contrast mode (Windows HC, increased contrast setting) | Increase contrast to 7:1, thicker borders, stronger colors | Chrome 96+, Edge 96+, Safari 14.1+ |
| less | Low contrast preference (light sensitivity) | Reduce contrast slightly, softer colors | Safari 14.1+, limited elsewhere |
| custom | Custom contrast settings | Respect system colors | Future spec |
Example: Responsive contrast adjustments
/* Standard contrast (default) */
.button {
background: #0066cc;
color: #ffffff;
border: 1px solid #0052a3;
}
/* High contrast mode */
@media (prefers-contrast: more) {
.button {
background: #003d7a; /* Darker for more contrast */
color: #ffffff;
border: 2px solid #000000; /* Thicker, stronger border */
font-weight: 600;
}
/* Ensure all UI components meet 7:1 ratio */
body {
--contrast-ratio: 7;
}
/* Stronger focus indicators */
:focus-visible {
outline: 3px solid #000000;
outline-offset: 3px;
}
}
/* Low contrast mode (light sensitivity) */
@media (prefers-contrast: less) {
.button {
background: #3385d6; /* Lighter blue */
color: #f5f5f5;
border: 1px solid #5c9dd9;
}
/* Reduce harsh contrasts */
body {
background: #f8f8f8;
color: #3a3a3a;
}
}
/* Windows High Contrast Mode detection */
@media (prefers-contrast: more) and (prefers-color-scheme: dark) {
/* User is in Windows High Contrast Dark theme */
.card {
border: 2px solid ButtonText;
background: Canvas;
color: CanvasText;
}
}
Windows High Contrast Mode: When enabled, Windows forces system colors. Use
prefers-contrast: more to detect and adjust. Test with forced-colors media query. Never use
background images for critical info in high contrast mode.
10.4 CSS Container Queries for Accessibility
| Use Case | Accessibility Benefit | Implementation | Browser Support |
|---|---|---|---|
| Responsive text sizing | Text adapts to container width, not viewport | Scale fonts based on component size for better readability | Chrome 105+, Safari 16+, Firefox 110+ |
| Component-level zoom | Better reflow at high zoom levels | Layouts adjust independently at 200%+ zoom | Meets WCAG 1.4.10 (Reflow) |
| Touch target sizing | Increase button size in narrow containers | Ensure 44px minimum in constrained spaces | Dynamic WCAG 2.5.5 compliance |
| Reading line length | Maintain optimal 50-75 character line length | Adjust columns based on container width | Improves readability (WCAG 1.4.8 AAA) |
| Focus indicator scaling | Focus rings scale with component size | Larger focus indicators in larger containers | Better WCAG 2.4.13 compliance |
Example: Accessible container queries
/* Enable container queries */
.card-container {
container-type: inline-size;
container-name: card;
}
/* Default: narrow card */
.card {
padding: 16px;
font-size: 14px;
}
.card h2 {
font-size: 18px;
}
.card button {
min-height: 44px;
padding: 8px 16px;
}
/* Medium container: increase spacing and text */
@container card (min-width: 400px) {
.card {
padding: 24px;
font-size: 16px;
}
.card h2 {
font-size: 24px;
line-height: 1.3;
}
.card button {
min-height: 48px;
padding: 12px 24px;
font-size: 16px;
}
}
/* Large container: optimal reading layout */
@container card (min-width: 600px) {
.card {
padding: 32px;
max-width: 65ch; /* Optimal line length */
}
.card p {
font-size: 18px;
line-height: 1.6;
margin-bottom: 1.5em;
}
}
/* Container query for zoom support */
@container (max-width: 320px) {
/* When container is narrow (e.g., at 200% zoom) */
.card {
/* Stack elements vertically */
flex-direction: column;
}
.card button {
width: 100%;
}
}
Container Query Benefits: Better reflow support at high zoom levels (WCAG 1.4.10). Components
adapt to available space, not just viewport. Improves readability by maintaining optimal line lengths. Enables
truly responsive components in complex layouts.
10.5 CSS Focus-visible Selectors
| Selector | When Applied | Use Case | Browser Support |
|---|---|---|---|
| :focus | Element has focus (keyboard or mouse) | Basic focus state - always shown | All browsers |
| :focus-visible | Keyboard focus only (heuristic-based) | Show focus ring for keyboard, hide for mouse clicks | All modern browsers |
| :focus-within | Element or descendant has focus | Style parent when child is focused | All modern browsers |
| :focus-visible:not(:focus) | Never (invalid) | Error: these states are mutually dependent | N/A |
| :has(:focus-visible) | Descendant has keyboard focus | Style container when child has focus (alternative to :focus-within) | Chrome 105+, Safari 15.4+ |
Example: Comprehensive focus-visible implementation
/* Remove default focus outline (be careful!) */
*:focus {
outline: none;
}
/* Custom focus-visible for keyboard navigation */
*:focus-visible {
outline: 2px solid #0066cc;
outline-offset: 2px;
border-radius: 2px;
}
/* Button: show focus only for keyboard */
.button {
border: 2px solid transparent;
transition: border-color 0.15s;
}
.button:focus-visible {
border-color: #0066cc;
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.2);
}
/* Input: always show focus (user is typing) */
input,
textarea,
select {
border: 1px solid #d0d0d0;
}
input:focus,
textarea:focus,
select:focus {
outline: 2px solid #0066cc;
outline-offset: 0;
border-color: #0066cc;
}
/* Focus-within: highlight form section when any field focused */
.form-section:focus-within {
background: #f5f9ff;
border-color: #0066cc;
}
/* High contrast mode: ensure focus is visible */
@media (prefers-contrast: more) {
*:focus-visible {
outline: 3px solid currentColor;
outline-offset: 3px;
}
}
/* Dark mode focus adjustment */
@media (prefers-color-scheme: dark) {
*:focus-visible {
outline-color: #66b3ff;
}
}
Focus-visible Warning: Never use
outline: none without providing an alternative
focus indicator. Always test with keyboard navigation. WCAG 2.4.13 requires 3:1 contrast ratio for focus
indicators against adjacent colors. Some browsers may show :focus-visible for mouse clicks on form inputs.
10.6 Scroll Behavior and Animations
| CSS Property | Accessibility Impact | Best Practice | WCAG Reference |
|---|---|---|---|
| scroll-behavior: smooth | Can cause motion sickness if animated | Disable in prefers-reduced-motion | 2.3.3 AAA (Animation from Interactions) |
| scroll-margin-top | Prevents content from hiding under sticky headers | Set to header height for skip links and anchor navigation | 2.4.1 AA (Bypass Blocks) |
| scroll-padding | Ensures focused elements fully visible | Add padding for sticky UI when element scrolled into view | 2.4.7 AA (Focus Visible) |
| overscroll-behavior | Prevents unwanted scroll chaining | Use 'contain' for modals to prevent body scroll | User experience improvement |
| scroll-snap-type | Can trap keyboard users if not careful | Ensure keyboard can reach all snap points, provide skip option | 2.1.1 AA (Keyboard) |
| position: sticky | Can obscure content if not accounted for | Use with scroll-margin-top on target elements | 1.4.10 AA (Reflow) |
Example: Accessible scroll behavior
/* Enable smooth scrolling (with reduced motion respect) */
html {
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
/* Sticky header with scroll compensation */
header {
position: sticky;
top: 0;
height: 60px;
background: white;
z-index: 100;
}
/* Ensure anchored content visible below sticky header */
:target {
scroll-margin-top: 80px; /* Header height + spacing */
}
/* Alternative: all headings (for skip links) */
h1, h2, h3, h4, h5, h6 {
scroll-margin-top: 80px;
}
/* Focus visibility with sticky elements */
html {
scroll-padding-top: 80px;
}
/* Accessible carousel with snap scrolling */
.carousel {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
}
@media (prefers-reduced-motion: reduce) {
.carousel {
scroll-snap-type: none;
scroll-behavior: auto;
}
}
.carousel-item {
scroll-snap-align: start;
scroll-snap-stop: always;
flex-shrink: 0;
width: 100%;
}
/* Modal: prevent background scrolling */
.modal {
overscroll-behavior: contain;
}
body.modal-open {
overflow: hidden;
}
/* Smooth scroll for skip links */
.skip-link:focus {
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
.skip-link:focus {
scroll-behavior: auto;
}
}
Scroll Accessibility Tips: Always respect
prefers-reduced-motion for smooth
scrolling. Use scroll-margin-top for all anchor targets and headings when using sticky headers.
Test keyboard navigation with scroll-snap (ensure all content reachable). Prevent modal background scroll with
overscroll-behavior: contain.
Modern CSS Accessibility Quick Reference
- Reduced Motion: Use
@media (prefers-reduced-motion: reduce)to disable/minimize animations; provide instant or crossfade alternatives to slide/parallax effects (WCAG 2.3.3) - Color Scheme: Implement dark mode with
prefers-color-scheme; maintain 4.5:1 contrast in both modes; use CSS custom properties for theming - Contrast: Support
prefers-contrast: morefor high contrast mode; increase to 7:1 ratios, thicker borders, stronger focus indicators - Container Queries: Use for better reflow at zoom levels; maintain optimal line lengths (50-75ch); ensure touch targets scale appropriately
- Focus-Visible: Show focus rings for keyboard only with
:focus-visible; maintain 3:1 contrast for focus indicators (WCAG 2.4.13) - Scroll Behavior: Set
scroll-behavior: autofor reduced motion; usescroll-margin-topfor sticky headers; ensurescroll-snapdoesn't trap keyboard users - Testing: Test with OS accessibility settings enabled; verify all media queries work; use browser DevTools to simulate preferences
- Browser Support: Most features in modern browsers (2022+); provide fallbacks or progressive enhancement for older browsers
- Key Properties: scroll-margin, scroll-padding, overscroll-behavior, container-type, focus-visible, color-scheme
- Tools: Chrome DevTools (Rendering panel), Firefox Accessibility Inspector, Safari Develop menu (prefers simulation)
11. JavaScript Accessibility APIs
11.1 DOM Manipulation for Screen Readers
| DOM Operation | Accessibility Impact | Best Practice | Screen Reader Behavior |
|---|---|---|---|
| innerHTML replacement | Screen reader may not announce changes | Use ARIA live regions or focus management | Silent update unless in live region |
| appendChild / insertBefore | New content may be missed by screen readers | Move focus to new content or use aria-live="polite" | Announced only if in live region or focused |
| removeChild / remove | Focus can be lost if focused element removed | Move focus to logical element before removal | May announce removal if in live region |
| setAttribute (ARIA) | Immediate announcement of state changes | Update aria-expanded, aria-pressed, aria-checked dynamically | Announces state: "expanded", "checked", etc. |
| classList.add/remove | Visual-only change unless affects ARIA or semantics | Pair with ARIA attribute updates for state changes | No announcement unless ARIA also updated |
| textContent update | Silent change unless in live region | Use aria-live or move focus for important updates | Announced if element has aria-live or aria-atomic |
| hidden attribute toggle | Removes from accessibility tree completely | Manage focus before hiding; use aria-hidden for visual hiding only | Element becomes unavailable to screen reader |
Example: Accessible DOM manipulation patterns
// BAD: Silent update - screen reader doesn't know
function updateBadge(count) {
document.querySelector('.badge').textContent = count;
}
// GOOD: Use live region for dynamic updates
function updateBadge(count) {
const badge = document.querySelector('.badge');
badge.textContent = count;
badge.setAttribute('aria-live', 'polite');
badge.setAttribute('aria-atomic', 'true');
}
// GOOD: Manage focus when adding content
function addNotification(message) {
const notification = document.createElement('div');
notification.setAttribute('role', 'alert');
notification.textContent = message;
document.body.appendChild(notification);
// role="alert" automatically announces in screen readers
}
// GOOD: Move focus before removing element
function deleteItem(itemId) {
const item = document.getElementById(itemId);
const nextFocusTarget = item.nextElementSibling ||
item.previousElementSibling ||
document.querySelector('.item-list');
// Move focus first
nextFocusTarget?.focus();
// Then remove
item.remove();
// Announce the action
announce(`Item deleted. ${getItemCount()} items remaining.`);
}
// Helper: Create accessible announcer
function announce(message, priority = 'polite') {
const announcer = document.getElementById('sr-announcer') ||
createAnnouncer();
announcer.textContent = message;
announcer.setAttribute('aria-live', priority);
}
function createAnnouncer() {
const div = document.createElement('div');
div.id = 'sr-announcer';
div.className = 'sr-only';
div.setAttribute('aria-live', 'polite');
div.setAttribute('aria-atomic', 'true');
document.body.appendChild(div);
return div;
}
Critical DOM Rules: Never remove focused elements without moving focus first. Always update
ARIA attributes alongside visual changes. Use
role="alert" for important messages (auto-announces).
Don't rely solely on CSS classes for state changes - screen readers won't detect them.
11.2 Event Handling and Accessibility
| Event Type | Accessibility Consideration | Keyboard Alternative | Implementation |
|---|---|---|---|
| click | Works for keyboard (Enter/Space on buttons) | Native for buttons/links; works with Enter key | Use on semantic buttons - handles keyboard automatically |
| mouseenter / mouseleave | Not accessible to keyboard users | Add focus/blur events for keyboard equivalent | Provide keyboard alternative for all hover actions |
| hover (CSS :hover) | Visual only - not keyboard accessible | Use :focus-visible alongside :hover | Ensure focus state provides same info as hover |
| dblclick | No keyboard equivalent | Provide single-click or button alternative | Avoid dblclick for critical actions |
| contextmenu (right-click) | Limited keyboard access | Provide menu button or keyboard shortcut | Show context menu on Shift+F10 or dedicated button |
| touchstart / touchend | Mobile-only, not accessible via keyboard | Use click event which works for touch and keyboard | Prefer click over touch events for better compatibility |
| keydown / keyup | Good for custom keyboard shortcuts | Document shortcuts; don't override standard keys | Use for arrow keys, Escape, custom shortcuts only |
| focus / blur | Essential for keyboard navigation | Primary keyboard interaction events | Use for showing/hiding content, validation feedback |
Example: Accessible event handling
// BAD: Mouse-only interaction
element.addEventListener('mouseenter', showTooltip);
element.addEventListener('mouseleave', hideTooltip);
// GOOD: Keyboard and mouse support
element.addEventListener('mouseenter', showTooltip);
element.addEventListener('mouseleave', hideTooltip);
element.addEventListener('focus', showTooltip);
element.addEventListener('blur', hideTooltip);
// GOOD: Accessible custom keyboard handler
function handleKeyboard(event) {
// Don't override browser shortcuts
if (event.ctrlKey || event.metaKey) return;
switch(event.key) {
case 'ArrowDown':
event.preventDefault();
focusNext();
break;
case 'ArrowUp':
event.preventDefault();
focusPrevious();
break;
case 'Home':
event.preventDefault();
focusFirst();
break;
case 'End':
event.preventDefault();
focusLast();
break;
case 'Escape':
closeDialog();
break;
}
}
// GOOD: Accessible click handler on custom element
const customButton = document.querySelector('.custom-button');
customButton.setAttribute('role', 'button');
customButton.setAttribute('tabindex', '0');
customButton.addEventListener('click', handleAction);
customButton.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleAction();
}
});
// GOOD: Context menu with keyboard support
element.addEventListener('contextmenu', (e) => {
e.preventDefault();
showContextMenu(e.clientX, e.clientY);
});
// Add keyboard trigger
element.addEventListener('keydown', (e) => {
if (e.key === 'F10' && e.shiftKey) {
e.preventDefault();
const rect = element.getBoundingClientRect();
showContextMenu(rect.left, rect.bottom);
}
});
Event Handling Rules: Always pair mouse events with keyboard equivalents. Use
click event for buttons (handles Enter/Space automatically). Don't prevent default behavior on
standard keyboard shortcuts. Test with keyboard-only navigation.
11.3 Dynamic Content Announcements
| Technique | aria-live Value | Use Case | Announcement Timing |
|---|---|---|---|
| role="alert" | Implicit: assertive | Critical errors, urgent notifications | Immediate - interrupts current speech |
| role="status" | Implicit: polite | Success messages, status updates | After current speech completes |
| aria-live="polite" | polite | Non-urgent updates (cart count, filter results) | After current speech completes |
| aria-live="assertive" | assertive | Time-sensitive warnings, validation errors | Immediate - interrupts current speech |
| aria-live="off" | off (default) | Disable announcements for frequent updates | No announcement |
| aria-atomic="true" | N/A (modifier) | Announce entire region, not just changed text | Reads full content on change |
| aria-relevant | N/A (modifier) | Control what changes are announced (additions, removals, text, all) | Default: "additions text" |
Example: Dynamic content announcement patterns
<!-- HTML: Live region containers (create once) -->
<div id="alert-region" role="alert" aria-live="assertive" aria-atomic="true"></div>
<div id="status-region" role="status" aria-live="polite" aria-atomic="true"></div>
<!-- Visual-hidden announcer for screen readers -->
<div id="sr-announcer" class="sr-only" aria-live="polite" aria-atomic="true"></div>
<style>
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
</style>
Example: JavaScript announcement utilities
// Announcement utility class
class A11yAnnouncer {
constructor() {
this.politeRegion = this.createRegion('polite');
this.assertiveRegion = this.createRegion('assertive');
}
createRegion(priority) {
const region = document.createElement('div');
region.className = 'sr-only';
region.setAttribute('aria-live', priority);
region.setAttribute('aria-atomic', 'true');
document.body.appendChild(region);
return region;
}
announce(message, priority = 'polite') {
const region = priority === 'assertive'
? this.assertiveRegion
: this.politeRegion;
// Clear and set new message
region.textContent = '';
setTimeout(() => {
region.textContent = message;
}, 100); // Small delay ensures announcement
}
announceError(message) {
this.announce(message, 'assertive');
}
announceSuccess(message) {
this.announce(message, 'polite');
}
}
// Usage
const announcer = new A11yAnnouncer();
// Form validation
form.addEventListener('submit', async (e) => {
e.preventDefault();
try {
await saveData();
announcer.announceSuccess('Form saved successfully');
} catch (error) {
announcer.announceError('Error: ' + error.message);
}
});
// Search results update
function updateSearchResults(results) {
displayResults(results);
announcer.announce(
`${results.length} results found for "${searchTerm}"`
);
}
// Loading state
function setLoading(isLoading) {
if (isLoading) {
announcer.announce('Loading...');
} else {
announcer.announce('Loading complete');
}
}
Live Region Pitfalls: Don't overuse assertive - it interrupts users. Live regions must exist in
DOM before updates (create on page load). Use aria-atomic="true" for complete messages. Avoid announcing every
keystroke or rapid updates. Clear and re-set content with timeout for reliable announcements.
11.4 Focus Management in SPAs
| Scenario | Focus Strategy | Implementation | WCAG Reference |
|---|---|---|---|
| Route change / page navigation | Focus main heading or skip link target | Set tabindex="-1" on heading, call focus(), announce page title | 2.4.3 AA (Focus Order) |
| Modal/dialog open | Focus first focusable element in modal | Store previous focus, trap focus in modal, restore on close | 2.4.3 AA |
| Item deletion | Focus next/previous item or parent container | Focus next sibling, previous sibling, or list container | 2.4.3 AA |
| Content expansion (accordion) | Keep focus on trigger button | Don't move focus when expanding - let user navigate into content | User expectation |
| Dynamic content insertion | Focus new content if user-initiated, or announce with live region | Move focus to heading in new section or use aria-live | 4.1.3 AA (Status Messages) |
| Form submission | Focus first error or success message | Move to error summary or confirmation message | 3.3.1 AA (Error Identification) |
| Infinite scroll | Maintain focus position, announce new items | Don't move focus; use live region to announce "X new items loaded" | 2.4.3 AA |
Example: SPA focus management
// Route change focus management
class Router {
navigate(newRoute) {
// Update content
this.updateContent(newRoute);
// Update document title
document.title = `${newRoute.title} - App Name`;
// Focus management strategy
const mainHeading = document.querySelector('h1');
if (mainHeading) {
// Make heading focusable
mainHeading.setAttribute('tabindex', '-1');
// Focus and announce
mainHeading.focus();
// Announce page change
this.announce(`Navigated to ${newRoute.title}`);
}
}
}
// Modal focus trap
class AccessibleModal {
constructor(modalElement) {
this.modal = modalElement;
this.previousFocus = null;
this.focusableSelectors =
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])';
}
open() {
// Store current focus
this.previousFocus = document.activeElement;
// Show modal
this.modal.style.display = 'block';
this.modal.setAttribute('aria-hidden', 'false');
// Get focusable elements
this.focusableElements = Array.from(
this.modal.querySelectorAll(this.focusableSelectors)
);
// Focus first element
if (this.focusableElements.length) {
this.focusableElements[0].focus();
}
// Add trap
this.modal.addEventListener('keydown', this.trapFocus.bind(this));
document.addEventListener('focus', this.returnFocus.bind(this), true);
}
trapFocus(e) {
if (e.key !== 'Tab') return;
const firstElement = this.focusableElements[0];
const lastElement = this.focusableElements[this.focusableElements.length - 1];
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
returnFocus(e) {
if (!this.modal.contains(e.target)) {
e.stopPropagation();
this.focusableElements[0]?.focus();
}
}
close() {
this.modal.style.display = 'none';
this.modal.setAttribute('aria-hidden', 'true');
// Remove trap
this.modal.removeEventListener('keydown', this.trapFocus);
document.removeEventListener('focus', this.returnFocus, true);
// Restore focus
this.previousFocus?.focus();
}
}
// Item deletion focus management
function deleteItem(itemElement) {
// Find next focus target
const nextItem = itemElement.nextElementSibling;
const prevItem = itemElement.previousElementSibling;
const parentList = itemElement.closest('[role="list"]');
const nextFocus = nextItem || prevItem || parentList;
// Remove item
itemElement.remove();
// Focus next logical element
if (nextFocus) {
if (nextFocus === parentList) {
nextFocus.setAttribute('tabindex', '-1');
}
nextFocus.focus();
}
// Announce deletion
announce('Item deleted');
}
SPA Focus Best Practices: Always manage focus on route changes (focus h1 with tabindex="-1").
Trap focus in modals and restore on close. Move focus to logical next element after deletions. Announce page
changes to screen readers. Use native <dialog> element for built-in focus management.
11.5 Accessibility Object Model (AOM)
| AOM Feature | Current Status | Purpose | Browser Support |
|---|---|---|---|
| Computed accessibility properties | Available (read-only) | Access computed role, name, description via JavaScript | Chrome 90+, Edge 90+ |
| element.computedRole | Available | Get effective ARIA role of element | Chrome 90+, Edge 90+ |
| element.computedLabel | Available | Get accessible name (from aria-label, labels, content) | Chrome 90+, Edge 90+ |
| element.ariaXXX properties | Available (ARIA reflection) | Set ARIA attributes via JavaScript properties | Chrome 81+, Firefox 119+, Safari 16.4+ |
| Accessibility events | Proposed (Phase 4) | Listen to screen reader interactions | Not yet implemented |
| Virtual accessibility nodes | Proposed (Phase 4) | Create accessibility tree nodes without DOM elements | Not yet implemented |
Example: Using ARIA reflection and computed properties
// ARIA Reflection: Set ARIA via JavaScript properties
const button = document.querySelector('button');
// Old way: setAttribute
button.setAttribute('aria-pressed', 'true');
button.setAttribute('aria-label', 'Toggle menu');
// New way: ARIA reflection (cleaner, type-safe)
button.ariaPressed = 'true';
button.ariaLabel = 'Toggle menu';
// Supports all ARIA properties
button.ariaExpanded = 'false';
button.ariaHasPopup = 'menu';
button.ariaDisabled = 'true';
// Read computed accessibility properties
console.log(button.computedRole); // "button"
console.log(button.computedLabel); // "Toggle menu"
// Validation: Check if element has accessible name
function validateAccessibleName(element) {
const label = element.computedLabel;
if (!label || label.trim() === '') {
console.warn('Element missing accessible name:', element);
return false;
}
return true;
}
// Debugging: Log accessibility tree info
function debugA11y(element) {
console.log({
role: element.computedRole,
name: element.computedLabel,
ariaExpanded: element.ariaExpanded,
ariaPressed: element.ariaPressed,
ariaDisabled: element.ariaDisabled
});
}
// Dynamic ARIA management
class ToggleButton {
constructor(element) {
this.element = element;
this.pressed = false;
}
toggle() {
this.pressed = !this.pressed;
// Use ARIA reflection
this.element.ariaPressed = String(this.pressed);
// Verify computed value
console.log('Computed label:', this.element.computedLabel);
}
}
// Feature detection
if ('ariaPressed' in Element.prototype) {
// Use ARIA reflection
button.ariaPressed = 'true';
} else {
// Fallback to setAttribute
button.setAttribute('aria-pressed', 'true');
}
AOM Benefits: ARIA reflection provides cleaner API than setAttribute (element.ariaLabel vs
setAttribute('aria-label')). Computed properties help validate accessibility tree. Better for TypeScript/type
safety. Always feature-detect before using (not all browsers support yet).
11.6 Web Components Accessibility
| Challenge | Problem | Solution | Example Pattern |
|---|---|---|---|
| Shadow DOM barrier | Labels can't reference inputs across shadow boundary | Use slots or duplicate labels inside shadow DOM | Pass label via slot or aria-label attribute |
| ARIA in shadow DOM | aria-labelledby/describedby don't cross boundaries | Use ElementInternals or replicate ARIA inside shadow | Copy aria-label from host to internal elements |
| Focus delegation | Focus on custom element doesn't focus internal input | Use delegatesFocus: true in attachShadow | shadowRoot with delegatesFocus option |
| Form participation | Custom inputs not recognized by forms | Use ElementInternals API for form association | attachInternals() + formAssociated: true |
| Keyboard navigation | Tab order can be confusing across shadow boundaries | Ensure logical tab order; use tabindex carefully | Test keyboard navigation thoroughly |
| Screen reader testing | Inconsistent behavior across screen readers | Test extensively; use semantic HTML in shadow | Prefer native elements over custom ARIA |
Example: Accessible web component patterns
// Accessible custom input with ElementInternals
class AccessibleInput extends HTMLElement {
static formAssociated = true;
constructor() {
super();
this.internals = this.attachInternals();
// Create shadow DOM with focus delegation
const shadow = this.attachShadow({
mode: 'open',
delegatesFocus: true
});
shadow.innerHTML = `
<style>
:host {
display: inline-block;
}
input {
padding: 8px;
border: 1px solid #ccc;
}
input:focus {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
</style>
<slot name="label"></slot>
<input type="text" id="input" />
`;
this.input = shadow.querySelector('input');
this.setupAccessibility();
}
setupAccessibility() {
// Forward ARIA attributes from host to input
const observer = new MutationObserver(() => {
this.updateARIA();
});
observer.observe(this, {
attributes: true,
attributeFilter: ['aria-label', 'aria-describedby', 'aria-required']
});
this.updateARIA();
// Handle input changes for form
this.input.addEventListener('input', () => {
this.internals.setFormValue(this.input.value);
});
}
updateARIA() {
// Copy ARIA from host to internal input
['aria-label', 'aria-describedby', 'aria-required'].forEach(attr => {
const value = this.getAttribute(attr);
if (value) {
this.input.setAttribute(attr, value);
} else {
this.input.removeAttribute(attr);
}
});
}
// Expose value for forms
get value() {
return this.input.value;
}
set value(val) {
this.input.value = val;
this.internals.setFormValue(val);
}
// Form validation
checkValidity() {
return this.internals.checkValidity();
}
}
customElements.define('accessible-input', AccessibleInput);
// Usage
// <accessible-input aria-label="Email address" aria-required="true">
// <label slot="label">Email</label>
// </accessible-input>
// Accessible button component
class AccessibleButton extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({
mode: 'open',
delegatesFocus: true
});
shadow.innerHTML = `
<style>
button {
padding: 12px 24px;
background: #0066cc;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background: #0052a3;
}
button:focus-visible {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
</style>
<button>
<slot></slot>
</button>
`;
this.button = shadow.querySelector('button');
// Forward click events
this.button.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('custom-click', {
bubbles: true,
composed: true
}));
});
}
}
customElements.define('accessible-button', AccessibleButton);
Web Component Accessibility Challenges: Shadow DOM creates accessibility barriers - labels
can't reference IDs across boundaries. Use
delegatesFocus: true for automatic focus management.
ElementInternals API required for form-associated custom elements. Always forward ARIA attributes from host to
shadow elements. Test thoroughly with screen readers.
JavaScript Accessibility APIs Quick Reference
- DOM Manipulation: Use ARIA live regions for dynamic updates; manage focus before removing elements; update ARIA attributes with visual changes
- Event Handling: Pair mouse events with keyboard equivalents (hover → focus); use click for buttons (handles Enter/Space); avoid dblclick for critical actions
- Announcements: role="alert" for urgent messages (assertive); role="status" for updates (polite); create live region on page load, update content to announce
- SPA Focus: Focus h1 on route change (tabindex="-1"); trap focus in modals; restore focus after deletion; announce page changes
- AOM: Use ARIA reflection (element.ariaLabel) instead of setAttribute; access computed accessibility properties for validation
- Web Components: Use delegatesFocus: true; forward ARIA from host to shadow elements; ElementInternals for form association
- Best Practices: Never remove focused elements without moving focus first; use role="alert" sparingly; test with screen readers
- Browser Support: ARIA reflection (Chrome 81+, Safari 16.4+); computedRole/Label (Chrome 90+); ElementInternals (Chrome 77+, Firefox 93+)
- Testing: Test keyboard navigation, screen reader announcements, focus management, live region updates
- Tools: Chrome Accessibility DevTools, Firefox Accessibility Inspector, Screen readers (NVDA, JAWS, VoiceOver)
12. Testing and Validation Tools
12.1 Automated Testing Integration
| Tool | Type | Best Use Case | Coverage |
|---|---|---|---|
| axe-core | JavaScript library | Comprehensive automated testing (unit, integration, E2E) | ~57% WCAG issues detectable |
| @axe-core/react | React DevTools | Development-time warnings in console | Real-time feedback during development |
| jest-axe | Jest matcher | Unit tests for React/Vue components | Automated a11y assertions in test suite |
| @axe-core/playwright | E2E testing | Full page accessibility scans in Playwright | Integration with Playwright test runner |
| cypress-axe | E2E testing | Accessibility checks in Cypress tests | Per-page and per-component scanning |
| pa11y | CLI/Node.js | Command-line testing, CI/CD integration | HTML CodeSniffer + custom rules |
| Lighthouse CI | CI/CD | Performance + accessibility scoring | Google Lighthouse accessibility audit |
| eslint-plugin-jsx-a11y | Linter | Static analysis of JSX code | Catches common React a11y mistakes |
Example: Automated testing setup
// Install dependencies
// npm install --save-dev jest-axe @testing-library/react @testing-library/jest-dom
// Jest setup (setupTests.js)
import { toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
// Component test with jest-axe
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';
import Button from './Button';
describe('Button accessibility', () => {
it('should not have accessibility violations', async () => {
const { container } = render(
<Button onClick={() => {}}>Click me</Button>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should have accessible name', () => {
const { getByRole } = render(<Button>Submit</Button>);
expect(getByRole('button', { name: 'Submit' })).toBeInTheDocument();
});
});
// Playwright with axe-core
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('homepage should not have accessibility violations', async ({ page }) => {
await page.goto('http://localhost:3000');
const accessibilityScanResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
// Cypress with cypress-axe
import 'cypress-axe';
describe('Accessibility tests', () => {
beforeEach(() => {
cy.visit('/');
cy.injectAxe();
});
it('should have no accessibility violations', () => {
cy.checkA11y();
});
it('should check specific component', () => {
cy.checkA11y('.modal-dialog');
});
it('should exclude specific elements', () => {
cy.checkA11y(null, {
exclude: ['.third-party-widget']
});
});
});
// ESLint configuration (.eslintrc.js)
module.exports = {
extends: [
'plugin:jsx-a11y/recommended'
],
plugins: ['jsx-a11y'],
rules: {
'jsx-a11y/anchor-is-valid': 'error',
'jsx-a11y/img-redundant-alt': 'error',
'jsx-a11y/no-autofocus': 'warn',
'jsx-a11y/label-has-associated-control': 'error'
}
};
Automated Testing Limitations: Automated tools catch ~30-50% of accessibility issues. They
can't detect: poor visual design, logical reading order, meaningful alt text quality, keyboard usability, screen
reader UX. Always combine with manual testing.
12.2 Screen Reader Testing Procedures
| Platform | Screen Reader | Key Commands | Testing Focus |
|---|---|---|---|
| Windows | NVDA (free) |
Ctrl: Stop speechInsert+Down: Read allH/Shift+H: HeadingsK/Shift+K: LinksD/Shift+D: LandmarksB/Shift+B: ButtonsF/Shift+F: Form fields
|
Heading structure, landmarks, form labels, ARIA announcements |
| Windows | JAWS (commercial) |
Insert+F5: Form fields listInsert+F6: Headings listInsert+F7: Links listR: Regions/landmarksInsert+F3: Elements list
|
Complex ARIA widgets, forms, tables, dynamic content |
| macOS | VoiceOver |
Cmd+F5: Toggle VOVO+A: Read allVO+Right/Left: NavigateVO+U: Rotor menuVO+Space: ActivateVO+Shift+Down: Into group
|
Safari/Chrome compatibility, mobile web, native app integration |
| iOS | VoiceOver |
Swipe right/left: Navigate Double-tap: Activate Two-finger Z: Back Rotor: Quick nav |
Touch gestures, mobile-specific patterns, responsive design |
| Android | TalkBack |
Swipe right/left: Navigate Double-tap: Activate Swipe down-then-up: Reading controls Local context menu: Actions |
Android web, PWA, custom actions, material design |
Example: Screen reader testing checklist
// Screen Reader Test Script
1. Page Structure Test
□ Navigate by headings (H key) - logical hierarchy?
□ Navigate by landmarks (D/R key) - proper regions?
□ Page title announced correctly?
□ Main content easily accessible?
2. Interactive Elements Test
□ Navigate by buttons (B key) - all announced?
□ Button states announced (pressed, expanded)?
□ Links (K key) - descriptive text, not "click here"?
□ Form fields (F key) - labels associated?
3. Dynamic Content Test
□ Form validation errors announced?
□ Live region updates announced?
□ Loading states announced?
□ Route changes announced (SPA)?
4. Complex Widgets Test
□ Modal dialog focus trapped?
□ Accordion expand/collapse announced?
□ Tab panels keyboard navigable?
□ Combobox autocomplete working?
5. Table Test
□ Table headers announced with cells?
□ Navigate by cell (Ctrl+Alt+Arrow)?
□ Row/column headers associated?
// Screen Reader Testing Script Example
describe('Screen reader announcements', () => {
it('should announce form errors', () => {
// Simulate screen reader
const form = document.querySelector('form');
const errorMessage = document.querySelector('[role="alert"]');
// Verify error in accessibility tree
expect(errorMessage).toHaveAttribute('role', 'alert');
expect(errorMessage).toHaveTextContent('Email is required');
});
it('should announce dynamic updates', () => {
const liveRegion = document.querySelector('[aria-live="polite"]');
expect(liveRegion).toBeInTheDocument();
// Update content
liveRegion.textContent = '3 items added to cart';
// Verify content updated
expect(liveRegion).toHaveTextContent('3 items added to cart');
});
});
Screen Reader Testing Gotchas: Different screen readers behave differently (test with at least
2). Browser choice matters (NVDA+Firefox vs Chrome). Mobile screen readers have different navigation. Virtual
cursor vs focus mode impacts testing. Always test in actual screen readers, not simulators.
12.3 Keyboard Navigation Testing
| Key/Combination | Expected Behavior | Test Scenario | Common Issues |
|---|---|---|---|
| Tab | Move focus forward through interactive elements | All buttons, links, inputs reachable in logical order | Skip important elements, illogical order, focus traps |
| Shift+Tab | Move focus backward | Reverse navigation works correctly | Order different from forward tab |
| Enter | Activate buttons, links, submit forms | All interactive elements respond to Enter | Custom buttons missing Enter handler |
| Space | Activate buttons, checkboxes, toggle switches | Buttons work with Space key | Custom buttons missing Space handler |
| Escape | Close modals, cancel actions, clear autocomplete | Modals/dialogs close, focus restored | No Escape handler, focus not restored |
| Arrow keys | Navigate within composite widgets (tabs, menus, lists) | Tab panels, radio groups, dropdown menus | Arrows move page instead of focus |
| Home/End | Jump to first/last item in lists, first/last character in inputs | Long lists, combobox options | Missing Home/End support in custom widgets |
| Page Up/Down | Scroll content or navigate by page in lists | Scroll containers, data grids | Focus lost when scrolling |
Example: Keyboard testing automation
// Keyboard navigation test utilities
class KeyboardTester {
// Simulate Tab key
static tab(element, shift = false) {
const event = new KeyboardEvent('keydown', {
key: 'Tab',
code: 'Tab',
keyCode: 9,
shiftKey: shift,
bubbles: true
});
element.dispatchEvent(event);
}
// Get all focusable elements
static getFocusableElements(container = document) {
const selector = [
'a[href]',
'button:not([disabled])',
'textarea:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(', ');
return Array.from(container.querySelectorAll(selector));
}
// Test tab order
static testTabOrder(expectedOrder) {
const focusable = this.getFocusableElements();
const actualOrder = focusable.map(el =>
el.getAttribute('data-testid') || el.textContent.trim()
);
console.log('Expected:', expectedOrder);
console.log('Actual:', actualOrder);
return JSON.stringify(expectedOrder) === JSON.stringify(actualOrder);
}
}
// Playwright keyboard testing
test('keyboard navigation works correctly', async ({ page }) => {
await page.goto('/');
// Tab through all interactive elements
await page.keyboard.press('Tab');
await expect(page.locator('button').first()).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.locator('a').first()).toBeFocused();
// Test Shift+Tab (reverse)
await page.keyboard.press('Shift+Tab');
await expect(page.locator('button').first()).toBeFocused();
// Test Enter activation
await page.keyboard.press('Enter');
// Verify button action occurred
});
// Testing-Library keyboard test
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('modal closes with Escape key', async () => {
const user = userEvent.setup();
const onClose = jest.fn();
const { getByRole } = render(
<Modal isOpen onClose={onClose}>
<h2>Modal Title</h2>
</Modal>
);
// Modal is open
expect(getByRole('dialog')).toBeInTheDocument();
// Press Escape
await user.keyboard('{Escape}');
// Modal should close
expect(onClose).toHaveBeenCalled();
});
// Manual test checklist
/*
Keyboard Navigation Test Checklist:
□ Tab reaches all interactive elements
□ Tab order follows visual/logical order
□ Shift+Tab works in reverse
□ No keyboard traps (can always Tab out)
□ Enter activates buttons and links
□ Space activates buttons and checkboxes
□ Escape closes modals and cancels actions
□ Focus indicators clearly visible (3:1 contrast)
□ Arrow keys work in composite widgets
□ Home/End jump to first/last items
□ No auto-focus unless necessary
□ Focus restored after modal/dropdown closes
*/
Keyboard Testing Tips: Unplug your mouse and navigate entire site with keyboard only. Test
focus visibility (3:1 contrast minimum). Verify no focus traps (can always Tab out). Check custom widgets follow
ARIA keyboard patterns. Test with browser zoom at 200%.
12.4 Color Contrast Validation
| Tool | Type | Features | Best For |
|---|---|---|---|
| WebAIM Contrast Checker | Web tool | Foreground/background ratio, WCAG level compliance | Quick manual checks, design validation |
| Chrome DevTools | Browser tool | Color picker with contrast ratio, suggestions | Real-time adjustments during development |
| Colour Contrast Analyser (CCA) | Desktop app | Eyedropper, pass/fail indicators, simulations | Pixel-level accuracy, design review |
| axe DevTools | Browser extension | Automated contrast checking, element highlighting | Full page audits, QA testing |
| Stark (Figma plugin) | Design tool | Contrast check in Figma, suggestions, color blindness sim | Design phase validation |
| APCA Calculator | Web tool | Advanced Perceptual Contrast Algorithm (future WCAG 3) | Forward-looking contrast validation |
| Polypane | Browser | Built-in contrast checking, visual impairment simulators | Professional accessibility testing |
Example: Contrast ratio requirements and calculations
// WCAG Contrast Requirements
/*
Text Size | WCAG AA | WCAG AAA
--------------------- | -------- | --------
Normal text (<18px) | 4.5:1 | 7:1
Large text (≥18px) | 3:1 | 4.5:1
Large bold (≥14px) | 3:1 | 4.5:1
UI components | 3:1 | N/A
Graphics/icons | 3:1 | N/A
Focus indicators | 3:1 | N/A (WCAG 2.4.13)
*/
// Contrast ratio calculation (relative luminance)
function getLuminance(r, g, b) {
const [R, G, B] = [r, g, b].map(val => {
val = val / 255;
return val <= 0.03928
? val / 12.92
: Math.pow((val + 0.055) / 1.055, 2.4);
});
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
}
function getContrastRatio(rgb1, rgb2) {
const lum1 = getLuminance(...rgb1);
const lum2 = getLuminance(...rgb2);
const lighter = Math.max(lum1, lum2);
const darker = Math.min(lum1, lum2);
return (lighter + 0.05) / (darker + 0.05);
}
// Usage
const textColor = [0, 0, 0]; // Black
const bgColor = [255, 255, 255]; // White
const ratio = getContrastRatio(textColor, bgColor);
console.log(`Contrast ratio: ${ratio.toFixed(2)}:1`); // 21:1
// Validation function
function meetsWCAG(ratio, level = 'AA', isLargeText = false) {
if (level === 'AAA') {
return isLargeText ? ratio >= 4.5 : ratio >= 7;
}
// AA
return isLargeText ? ratio >= 3 : ratio >= 4.5;
}
// Automated contrast checking in tests
import { toHaveNoViolations } from 'jest-axe';
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';
test('button has sufficient contrast', async () => {
const { container } = render(
<button style={{ background: '#0066cc', color: '#ffffff' }}>
Click me
</button>
);
const results = await axe(container, {
rules: {
'color-contrast': { enabled: true }
}
});
expect(results).toHaveNoViolations();
});
// CSS color contrast validation
/*
Good contrast examples:
- #000000 on #FFFFFF = 21:1 (AAA)
- #0066CC on #FFFFFF = 8.59:1 (AAA)
- #666666 on #FFFFFF = 5.74:1 (AA)
- #FFFFFF on #0066CC = 8.59:1 (AAA)
Poor contrast examples (avoid):
- #777777 on #FFFFFF = 4.47:1 (fails AA for normal text)
- #0066CC on #000000 = 2.44:1 (fails AA)
- #CCCCCC on #FFFFFF = 1.61:1 (fails all)
*/
Contrast Testing Gotchas: Test with actual rendered colors, not design specs (gradients,
opacity, overlays affect contrast). Images with text need manual checking. Focus indicators need 3:1 against
both adjacent colors. Disabled elements don't need to meet contrast (but consider UX).
12.5 Manual Testing Checklists
| Test Category | Key Checkpoints | Tools Needed | WCAG Coverage |
|---|---|---|---|
| Keyboard Navigation |
✓ All interactive elements reachable ✓ Logical tab order ✓ Visible focus indicators (3:1) ✓ No keyboard traps ✓ Escape closes modals |
Keyboard only, DevTools | 2.1.1, 2.1.2, 2.4.3, 2.4.7, 2.4.13 |
| Screen Reader |
✓ Headings in logical order ✓ Landmarks identify regions ✓ Images have alt text ✓ Form labels associated ✓ Errors announced |
NVDA/JAWS/VoiceOver | 1.1.1, 1.3.1, 2.4.6, 3.3.1, 4.1.2 |
| Zoom & Reflow |
✓ Text to 200% without horizontal scroll ✓ Content reflows at 320px ✓ No loss of content/functionality ✓ Touch targets at least 24×24px |
Browser zoom, mobile view | 1.4.4, 1.4.10, 2.5.8 |
| Color & Contrast |
✓ Text contrast 4.5:1 (AA) ✓ UI components 3:1 ✓ Info not conveyed by color alone ✓ Color blindness friendly |
Contrast checker, color filters | 1.4.1, 1.4.3, 1.4.11 |
| Forms |
✓ All inputs have labels ✓ Required fields indicated ✓ Error messages clear & helpful ✓ Success confirmation ✓ Keyboard accessible |
Screen reader, keyboard | 1.3.1, 3.3.1, 3.3.2, 3.3.3, 4.1.2 |
| Dynamic Content |
✓ Updates announced (live regions) ✓ Focus managed on changes ✓ Loading states announced ✓ Time limits adjustable |
Screen reader, DevTools | 2.2.1, 4.1.2, 4.1.3 |
| Mobile/Touch |
✓ Touch targets 44×44px minimum ✓ Works in portrait & landscape ✓ Zoom not disabled ✓ Gestures have alternatives |
Real device, mobile SR | 1.3.4, 1.4.4, 2.5.1, 2.5.8 |
Example: Comprehensive testing checklist
// Accessibility Testing Checklist Template
=== KEYBOARD NAVIGATION ===
□ Tab through entire page (top to bottom)
□ Verify all interactive elements reachable
□ Check tab order matches visual order
□ Shift+Tab works in reverse
□ Focus visible on all elements (3:1 contrast)
□ No keyboard traps detected
□ Enter activates buttons/links
□ Space activates buttons/checkboxes
□ Escape closes modals/dropdowns
□ Arrow keys work in custom widgets
=== SCREEN READER ===
□ Use heading navigation (H key) - logical structure
□ Navigate by landmarks (D/R key) - proper regions
□ Check all images have alt text
□ Verify form labels read correctly
□ Test form validation announcements
□ Check ARIA states announced (expanded, pressed, etc.)
□ Verify live region updates announced
□ Test table navigation (headers announced)
□ Check custom widget announcements
=== VISUAL & CONTRAST ===
□ Text contrast meets 4.5:1 (normal) or 3:1 (large)
□ UI component contrast meets 3:1
□ Focus indicators visible (3:1 against adjacent)
□ Information not conveyed by color alone
□ Test with color blindness simulator
□ Check in high contrast mode (Windows)
=== ZOOM & REFLOW ===
□ Zoom to 200% - no horizontal scroll
□ Zoom to 400% for text - content visible
□ Resize to 320px width - content reflows
□ No loss of content at any zoom level
□ Touch targets remain 24×24px minimum
□ Test responsive design breakpoints
=== FORMS ===
□ All inputs have visible labels
□ Labels programmatically associated
□ Required fields clearly indicated
□ Error messages clear and specific
□ Errors announced by screen reader
□ Success confirmation provided
□ Can submit with keyboard
=== MEDIA ===
□ Videos have captions
□ Audio has transcripts
□ Auto-play videos can be paused
□ Media controls keyboard accessible
□ Transcripts provided for audio-only
=== MOBILE ===
□ Test with VoiceOver (iOS)
□ Test with TalkBack (Android)
□ Touch targets 44×44px minimum
□ Works in both orientations
□ Zoom not disabled (no user-scalable=no)
□ Gestures have button alternatives
=== DYNAMICS ===
□ Route changes announced (SPA)
□ Loading states announced
□ Form submission feedback
□ Modal opens - focus trapped
□ Modal closes - focus restored
□ Live regions working (cart updates, etc.)
=== DOCUMENTATION ===
□ Document any known issues
□ Note browser/AT combinations tested
□ Include WCAG conformance level
□ List exemptions (if any)
□ Provide remediation timeline
Manual Testing Best Practices: Test with real users with disabilities when possible. Use
multiple screen readers (NVDA, JAWS, VoiceOver). Test on actual mobile devices, not just emulators. Document
test results with screenshots/videos. Retest after fixes to verify remediation.
12.6 Accessibility Linting and CI/CD
| Tool/Approach | Integration Point | Configuration | Benefits |
|---|---|---|---|
| ESLint jsx-a11y | Pre-commit, IDE, CI | Rules for React/JSX accessibility | Catches issues during coding; instant feedback |
| Prettier (formatting) | Pre-commit, CI | Consistent ARIA attribute formatting | Code consistency, readability |
| Stylelint a11y | Pre-commit, CI | CSS accessibility rules (contrast, outline, etc.) | Prevents CSS-based a11y issues |
| Pa11y CI | GitHub Actions, GitLab CI | Automated page scans on deployment | Catches regressions before production |
| Lighthouse CI | GitHub Actions, GitLab CI | Performance + accessibility scoring | Metrics tracking, budget enforcement |
| axe-core in tests | Unit tests, E2E tests | Jest/Playwright/Cypress integration | Automated testing in CI pipeline |
| Git pre-commit hooks | Local git hooks (Husky) | Run linters before commit | Prevents committing a11y violations |
| Storybook a11y addon | Component library | axe checks in Storybook | Component-level validation during development |
Example: CI/CD accessibility pipeline
// package.json scripts
{
"scripts": {
"lint:a11y": "eslint --ext .js,.jsx,.ts,.tsx src/",
"test:a11y": "jest --testMatch '**/*.a11y.test.js'",
"audit:a11y": "pa11y-ci --config .pa11yci.json",
"lighthouse": "lighthouse-ci --config=.lighthouserc.json"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"eslint --fix",
"jest --findRelatedTests --passWithNoTests"
]
}
}
// .eslintrc.js
module.exports = {
extends: [
'plugin:jsx-a11y/recommended'
],
plugins: ['jsx-a11y'],
rules: {
'jsx-a11y/alt-text': 'error',
'jsx-a11y/anchor-has-content': 'error',
'jsx-a11y/anchor-is-valid': 'error',
'jsx-a11y/aria-props': 'error',
'jsx-a11y/aria-role': 'error',
'jsx-a11y/aria-unsupported-elements': 'error',
'jsx-a11y/label-has-associated-control': 'error',
'jsx-a11y/no-autofocus': 'warn'
}
};
// .pa11yci.json (Pa11y CI configuration)
{
"defaults": {
"standard": "WCAG2AA",
"runners": ["axe", "htmlcs"],
"timeout": 10000,
"wait": 1000,
"chromeLaunchConfig": {
"args": ["--no-sandbox"]
}
},
"urls": [
"http://localhost:3000",
"http://localhost:3000/about",
"http://localhost:3000/products"
]
}
// .lighthouserc.json (Lighthouse CI)
{
"ci": {
"collect": {
"url": ["http://localhost:3000"],
"numberOfRuns": 3
},
"assert": {
"assertions": {
"categories:accessibility": ["error", {"minScore": 0.9}],
"aria-allowed-attr": "error",
"aria-required-attr": "error",
"button-name": "error",
"color-contrast": "error",
"duplicate-id-aria": "error",
"html-has-lang": "error",
"image-alt": "error",
"label": "error",
"link-name": "error",
"list": "error"
}
}
}
}
// GitHub Actions workflow (.github/workflows/a11y.yml)
name: Accessibility Tests
on: [push, pull_request]
jobs:
accessibility:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Lint accessibility
run: npm run lint:a11y
- name: Build app
run: npm run build
- name: Start server
run: npm start &
- name: Wait for server
run: npx wait-on http://localhost:3000
- name: Run Pa11y CI
run: npm run audit:a11y
- name: Run Lighthouse CI
run: |
npm install -g @lhci/cli
lhci autorun
- name: Upload results
uses: actions/upload-artifact@v3
if: always()
with:
name: accessibility-reports
path: |
pa11y-results/
.lighthouseci/
// Storybook a11y addon (.storybook/preview.js)
import { withA11y } from '@storybook/addon-a11y';
export const decorators = [withA11y];
export const parameters = {
a11y: {
config: {
rules: [
{
id: 'color-contrast',
enabled: true
}
]
}
}
};
CI/CD Gotchas: Automated tools catch only 30-50% of issues - still need manual testing. Don't
block deployments for minor warnings. Set appropriate thresholds (e.g., 90% Lighthouse score). Test with
production-like data. Run tests on every PR, not just main branch.
Testing and Validation Quick Reference
- Automated Tools: Use axe-core (jest-axe, @axe-core/playwright, cypress-axe) for ~30-50% coverage; combine with manual testing
- Screen Readers: Test with NVDA (Windows free), JAWS (commercial), VoiceOver (macOS/iOS), TalkBack (Android)
- Keyboard: Tab through entire page, verify focus visibility (3:1 contrast), test Enter/Space/Escape, check no keyboard traps
- Contrast: Use WebAIM checker, Chrome DevTools, or CCA; verify 4.5:1 for text, 3:1 for UI components
- Manual Checklist: Keyboard navigation, screen reader, zoom/reflow, color/contrast, forms, media, mobile, dynamics
- Linting: eslint-plugin-jsx-a11y for React, stylelint-a11y for CSS, run in pre-commit hooks and CI
- CI/CD: Pa11y CI for automated scans, Lighthouse CI for metrics, axe in test suites, Storybook addon for components
- Testing Strategy: Shift-left (test early), automate what you can, manual test critical flows, retest after fixes
- Browser DevTools: Chrome Accessibility tree, Firefox Accessibility Inspector, Edge Accessibility Insights
- Best Practice: Test with real users with disabilities when possible; document all test results and remediation plans
13. Performance and Accessibility Optimization
13.1 Lazy Loading Accessible Implementation
| Technique | Accessibility Consideration | Implementation | WCAG Impact |
|---|---|---|---|
| Native lazy loading (images) | Alt text must be present before loading | <img src="..." alt="..." loading="lazy"> |
No impact if alt text provided (1.1.1) |
| Intersection Observer | Announce loading states with ARIA live regions | Load content when visible; update aria-busy during load | Ensure loading announced (4.1.3) |
| Infinite scroll | Provide "Load more" button alternative | Auto-load + manual button; announce new items loaded | User control required (2.2.2, 2.4.1) |
| Route-based code splitting | Manage focus on navigation, announce page change | Show loading indicator, focus h1 when loaded | Focus order maintained (2.4.3) |
| Component lazy loading | Loading skeleton must have accessible name | Use aria-label="Loading..." on placeholder | Status messages announced (4.1.3) |
| Defer off-screen content | Ensure keyboard users can reach lazy-loaded content | Load before focus reaches; maintain tab order | Keyboard accessible (2.1.1) |
| Image placeholders | Low-quality placeholder needs same alt text | Alt text on placeholder, maintain on full image | Text alternative always present (1.1.1) |
Example: Accessible lazy loading patterns
<!-- Native lazy loading with alt text -->
<img
src="high-res-image.jpg"
alt="Sunset over mountain range with orange and pink sky"
loading="lazy"
width="800"
height="600"
>
<!-- Intersection Observer with loading state -->
<div
id="content-section"
aria-busy="true"
aria-label="Loading more items..."
>
<!-- Loading skeleton -->
</div>
<script>
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
loadContent(entry.target);
}
});
}, { rootMargin: '100px' });
async function loadContent(container) {
container.setAttribute('aria-busy', 'true');
try {
const content = await fetchContent();
container.innerHTML = content;
container.removeAttribute('aria-busy');
// Announce completion
announce('New content loaded');
} catch (error) {
container.setAttribute('aria-busy', 'false');
announce('Error loading content', 'assertive');
}
}
observer.observe(document.getElementById('content-section'));
</script>
<!-- Accessible infinite scroll -->
<div role="feed" aria-label="News articles">
<!-- Articles here -->
</div>
<!-- Manual load alternative -->
<button
id="load-more"
aria-live="polite"
aria-atomic="true"
>
Load more articles
</button>
<script>
let autoLoadEnabled = true;
// Auto-load on scroll
window.addEventListener('scroll', () => {
if (!autoLoadEnabled) return;
const scrollable = document.documentElement.scrollHeight - window.innerHeight;
const scrolled = window.scrollY;
if (scrolled / scrollable > 0.8) {
loadMoreArticles();
}
});
// Manual load button
document.getElementById('load-more').addEventListener('click', () => {
autoLoadEnabled = false;
loadMoreArticles();
});
async function loadMoreArticles() {
const button = document.getElementById('load-more');
button.textContent = 'Loading...';
button.disabled = true;
const newArticles = await fetchArticles();
appendArticles(newArticles);
// Announce to screen readers
button.textContent = `${newArticles.length} new articles loaded. Load more`;
button.disabled = false;
// Alternative: use live region
announce(`${newArticles.length} new articles added to feed`);
}
</script>
<!-- React lazy loading with Suspense -->
import { lazy, Suspense } from 'react';
const HeavyComponent = lazy(() => import('./HeavyComponent'));
function App() {
return (
<Suspense fallback={
<div aria-live="polite" aria-busy="true">
<span className="sr-only">Loading component...</span>
<div className="spinner" aria-hidden="true"></div>
</div>
}>
<HeavyComponent />
</Suspense>
);
}
Lazy Loading Pitfalls: Don't lazy load critical content above the fold. Ensure alt text present
before image loads. Announce loading states for dynamic content. Provide manual alternatives for infinite
scroll. Maintain focus position when lazy loading near focused element.
13.2 Progressive Enhancement Strategies
| Layer | Base Experience | Enhanced Experience | Accessibility Benefit |
|---|---|---|---|
| HTML (Structure) | Semantic HTML, native elements | Custom components, ARIA enhancements | Works without JS; screen reader compatible |
| CSS (Presentation) | Readable without CSS, logical source order | Enhanced layouts, animations, theming | Content accessible if CSS fails to load |
| JavaScript (Behavior) | Forms submit to server, links navigate | Client-side validation, SPA navigation, AJAX | Core functionality works without JS |
| Images | Alt text, semantic HTML | Responsive images, lazy loading, WebP | Alt text ensures content accessible |
| Forms | Native HTML5 validation, server-side validation | Enhanced client-side validation, auto-save | Works with JS disabled; native validation accessible |
| Navigation | Standard links, skip links | SPA routing, smooth scroll, prefetching | Links work universally; progressive for performance |
| Media | Captions, transcripts in HTML | Custom players, adaptive streaming | Captions always available; enhanced player optional |
Example: Progressive enhancement patterns
<!-- BASE: Works without JavaScript -->
<form action="/search" method="GET">
<label for="search-input">Search</label>
<input
type="search"
id="search-input"
name="q"
required
minlength="2"
>
<button type="submit">Search</button>
</form>
<!-- ENHANCED: JavaScript adds autocomplete -->
<script>
// Feature detection
if ('IntersectionObserver' in window) {
const searchInput = document.getElementById('search-input');
const form = searchInput.closest('form');
// Enhance with autocomplete
searchInput.setAttribute('role', 'combobox');
searchInput.setAttribute('aria-autocomplete', 'list');
searchInput.setAttribute('aria-expanded', 'false');
// Create autocomplete list
const listbox = document.createElement('ul');
listbox.setAttribute('role', 'listbox');
listbox.id = 'autocomplete-list';
searchInput.setAttribute('aria-controls', 'autocomplete-list');
form.appendChild(listbox);
// Add autocomplete functionality
searchInput.addEventListener('input', async (e) => {
const results = await fetchSuggestions(e.target.value);
displaySuggestions(results);
});
// Form still submits normally if autocomplete fails
}
</script>
<!-- BASE: Native disclosure -->
<details>
<summary>Show more information</summary>
<p>Additional content here...</p>
</details>
<!-- ENHANCED: Custom accordion with animation -->
<script>
// Enhance details element if animations supported
if (window.matchMedia('(prefers-reduced-motion: no-preference)').matches) {
const details = document.querySelector('details');
const content = details.querySelector('p');
// Add smooth animation
details.addEventListener('toggle', () => {
if (details.open) {
content.style.animation = 'slideDown 0.3s ease-out';
}
});
}
</script>
<!-- Progressive image loading -->
<picture>
<!-- Base: JPEG for all browsers -->
<source srcset="image.webp" type="image/webp">
<img
src="image.jpg"
alt="Product photo showing red sneakers"
loading="lazy"
width="600"
height="400"
>
</picture>
<!-- CSS Progressive Enhancement -->
<style>
/* Base: Works everywhere */
.button {
padding: 12px 24px;
background: #0066cc;
color: white;
border: 2px solid #0066cc;
}
/* Enhanced: Modern browsers */
@supports (display: grid) {
.layout {
display: grid;
gap: 20px;
}
}
/* Enhanced: Interaction */
@media (hover: hover) {
.button:hover {
background: #0052a3;
}
}
/* Enhanced: Animations for users who allow them */
@media (prefers-reduced-motion: no-preference) {
.button {
transition: background 0.2s ease;
}
}
</style>
<!-- JavaScript feature detection -->
<script>
// Check for feature support before enhancing
class ProgressiveEnhancer {
static enhance() {
// Check required features
if (!this.supportsFeatures()) {
console.log('Using base experience');
return;
}
// Add enhancements
this.enhanceForms();
this.enhanceNavigation();
}
static supportsFeatures() {
return (
'IntersectionObserver' in window &&
'requestIdleCallback' in window &&
'fetch' in window
);
}
static enhanceForms() {
// Add client-side validation
// Add auto-save
// Add inline feedback
}
static enhanceNavigation() {
// Add SPA routing
// Add prefetching
// Add smooth scroll
}
}
// Only enhance if supported
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
ProgressiveEnhancer.enhance();
});
} else {
ProgressiveEnhancer.enhance();
}
</script>
Progressive Enhancement Benefits: Core content/functionality always accessible. Graceful
degradation for older browsers/assistive tech. Better performance (load only what's needed). More resilient to
JavaScript errors. Improved SEO and initial render.
13.3 Critical Path and Assistive Technologies
| Optimization | AT Impact | Implementation | Performance Benefit |
|---|---|---|---|
| Critical CSS inline | Ensures focus indicators visible immediately | Inline styles for above-fold, focus states, ARIA | Faster First Contentful Paint (FCP) |
| Defer non-critical JS | Core HTML accessible before JS loads | Use defer/async; load enhancements after interactive | Faster Time to Interactive (TTI) |
| Preload fonts | Prevent text reflow that breaks screen reader context | <link rel="preload" href="font.woff2" as="font"> |
Reduce Cumulative Layout Shift (CLS) |
| Resource hints | Faster navigation for keyboard/AT users | dns-prefetch, preconnect for critical domains | Reduce navigation latency |
| Priority hints | Ensure alt text images load first | <img fetchpriority="high"> for hero images |
Optimize Largest Contentful Paint (LCP) |
| Service worker caching | Offline access for all users including AT users | Cache critical HTML, CSS, and accessibility features | Instant repeat visits |
| Reduce DOM size | Faster screen reader navigation and parsing | Flatten nesting, remove unnecessary divs | Lower memory usage, faster rendering |
Example: Critical path optimization for accessibility
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Page Title</title>
<!-- Critical CSS inline (includes focus styles, basic layout) -->
<style>
/* Critical styles for accessibility */
*:focus-visible {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
/* Critical layout */
body {
margin: 0;
font-family: system-ui, sans-serif;
line-height: 1.5;
}
main {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
</style>
<!-- Preload critical font (prevents layout shift) -->
<link
rel="preload"
href="/fonts/main-font.woff2"
as="font"
type="font/woff2"
crossorigin
>
<!-- Preconnect to critical domains -->
<link rel="preconnect" href="https://api.example.com">
<link rel="dns-prefetch" href="https://cdn.example.com">
<!-- Defer non-critical CSS -->
<link
rel="stylesheet"
href="/css/styles.css"
media="print"
onload="this.media='all'"
>
</head>
<body>
<!-- Skip link (critical for keyboard users) -->
<a href="#main-content" class="sr-only">Skip to main content</a>
<header>
<h1>Site Title</h1>
<nav aria-label="Main navigation">
<!-- Navigation -->
</nav>
</header>
<main id="main-content">
<!-- Critical above-fold content -->
<h2>Page Heading</h2>
<!-- Hero image with priority -->
<img
src="hero.jpg"
alt="Description of hero image"
fetchpriority="high"
width="1200"
height="600"
>
</main>
<!-- Defer non-critical JavaScript -->
<script defer src="/js/main.js"></script>
<!-- Load analytics asynchronously -->
<script async src="/js/analytics.js"></script>
</body>
</html>
<!-- Service Worker for offline accessibility -->
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').then(registration => {
console.log('Service Worker registered');
});
}
</script>
<!-- sw.js (Service Worker) -->
const CACHE_VERSION = 'v1';
const CRITICAL_CACHE = [
'/',
'/css/critical.css',
'/js/accessibility.js',
'/fonts/main-font.woff2'
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_VERSION).then(cache => {
return cache.addAll(CRITICAL_CACHE);
})
);
});
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(response => {
return response || fetch(event.request);
})
);
});
</script>
Critical Path Warnings: Don't defer CSS with focus styles - inline them. Ensure skip links work
before JavaScript loads. Test with slow 3G connections. Screen readers may struggle with large DOM trees (>1500
nodes). Always measure real-world AT performance.
13.4 Bundle Optimization for A11y Libraries
| Library/Technique | Size Impact | Optimization Strategy | Trade-offs |
|---|---|---|---|
| focus-trap-react | ~3KB gzipped | Use only for complex modals; native dialog element when possible | Native dialog has better browser integration but less control |
| react-aria | ~50-100KB (tree-shakeable) | Import only needed hooks; use modular imports | Comprehensive but large; consider lighter alternatives |
| @reach/ui | ~30KB per component | Import individual components, not full package | Accessible but adds bundle size |
| axe-core (dev only) | ~500KB (dev dependency) | Strip from production builds; use only in tests | Critical for testing but must not ship to prod |
| Custom ARIA utilities | 1-5KB custom code | Write lightweight utilities for common patterns | More maintenance but smaller bundle |
| Polyfills (inert, dialog) | ~10-20KB | Load conditionally based on feature detection | Only users with older browsers pay the cost |
| ARIA live region library | ~2KB custom | Build minimal announcer utility instead of library | Custom code needs testing but very light |
Example: Bundle optimization strategies
// BAD: Import entire library
import * as ReachUI from '@reach/ui';
// GOOD: Import only what you need
import { Dialog } from '@reach/dialog';
import '@reach/dialog/styles.css';
// BETTER: Use native when possible
// <dialog> element has built-in focus trap and accessibility
// Tree-shaking react-aria
// BAD: Imports everything
import { useButton, useDialog, useFocusRing } from 'react-aria';
// GOOD: Modular imports (better tree-shaking)
import { useButton } from '@react-aria/button';
import { useDialog } from '@react-aria/dialog';
import { useFocusRing } from '@react-aria/focus';
// Conditional polyfill loading
async function loadPolyfillsIfNeeded() {
const polyfills = [];
// Inert polyfill (for focus trapping)
if (!('inert' in HTMLElement.prototype)) {
polyfills.push(import('wicg-inert'));
}
// Dialog polyfill
if (!window.HTMLDialogElement) {
polyfills.push(import('dialog-polyfill'));
}
await Promise.all(polyfills);
}
// Lightweight custom announcer (vs importing library)
class Announcer {
constructor() {
this.region = this.createRegion();
}
createRegion() {
const div = document.createElement('div');
div.className = 'sr-only';
div.setAttribute('aria-live', 'polite');
div.setAttribute('aria-atomic', 'true');
document.body.appendChild(div);
return div;
}
announce(message) {
this.region.textContent = '';
setTimeout(() => {
this.region.textContent = message;
}, 100);
}
}
// Size: ~0.5KB vs ~5KB for a library
// Webpack configuration for optimization
module.exports = {
optimization: {
usedExports: true, // Tree shaking
sideEffects: false,
splitChunks: {
cacheGroups: {
// Separate a11y libraries for caching
accessibility: {
test: /[\\/]node_modules[\\/](@react-aria|@reach)/,
name: 'a11y-vendors',
chunks: 'all',
},
},
},
},
// Don't include axe-core in production
externals: process.env.NODE_ENV === 'production' ? {
'axe-core': 'axe-core',
'@axe-core/react': '@axe-core/react'
} : {},
};
// Vite configuration
export default {
build: {
rollupOptions: {
output: {
manualChunks: {
'a11y': [
'@react-aria/button',
'@react-aria/dialog',
'focus-trap-react'
]
}
}
}
}
};
// Measure bundle impact
// Run: npx webpack-bundle-analyzer dist/stats.json
// Example package.json for production builds
{
"scripts": {
"build": "NODE_ENV=production webpack --mode production",
"analyze": "webpack-bundle-analyzer dist/stats.json"
},
"dependencies": {
"@reach/dialog": "^0.18.0"
},
"devDependencies": {
"axe-core": "^4.7.0",
"@axe-core/react": "^4.7.0",
"webpack-bundle-analyzer": "^4.9.0"
}
}
Bundle Optimization Tips: Use native browser features when available (dialog, details, inert).
Import only specific functions, not entire libraries. Strip dev-only accessibility tools (axe-core) from
production. Use dynamic imports for large a11y features. Measure with webpack-bundle-analyzer.
13.5 Runtime Performance Considerations
| Performance Issue | AT Impact | Solution | Measurement |
|---|---|---|---|
| Heavy ARIA DOM updates | Screen readers lag or miss announcements | Debounce updates, batch DOM mutations, use requestIdleCallback | Monitor main thread blocking time |
| Large accessibility tree | Slow screen reader navigation (>1500 nodes) | Virtualize long lists, lazy load off-screen content | Count accessibility tree nodes in DevTools |
| Live region spam | Excessive announcements overwhelm users | Throttle updates, combine multiple changes into one announcement | Test with screen reader enabled |
| Focus management overhead | Janky focus transitions | Use CSS containment, minimize reflows on focus | Lighthouse Performance score, TBT metric |
| ARIA attribute thrashing | Screen reader interruptions | Update ARIA atomically, avoid rapid toggles | Screen reader testing |
| Memory leaks (event listeners) | Degraded AT performance over time | Clean up event listeners, use AbortController | Chrome DevTools Memory profiler |
| Layout thrashing | Slow response to keyboard navigation | Batch reads and writes, use CSS transforms for position | Performance DevTools, measure frame rate |
Example: Runtime performance optimization
// Debounce live region updates
class ThrottledAnnouncer {
constructor(delay = 1000) {
this.delay = delay;
this.timer = null;
this.region = this.createRegion();
}
createRegion() {
const div = document.createElement('div');
div.className = 'sr-only';
div.setAttribute('aria-live', 'polite');
div.setAttribute('aria-atomic', 'true');
document.body.appendChild(div);
return div;
}
announce(message) {
clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.region.textContent = message;
}, this.delay);
}
}
// Usage: Prevents announcement spam
const announcer = new ThrottledAnnouncer(1000);
// Instead of announcing every character typed
input.addEventListener('input', (e) => {
announcer.announce(`${e.target.value.length} characters entered`);
});
// Batch DOM updates for accessibility tree
function updateAccessibilityTree(items) {
// BAD: Multiple forced reflows
items.forEach(item => {
item.setAttribute('aria-selected', 'false');
item.setAttribute('aria-expanded', 'false');
});
// GOOD: Batch updates
requestAnimationFrame(() => {
items.forEach(item => {
item.setAttribute('aria-selected', 'false');
item.setAttribute('aria-expanded', 'false');
});
});
}
// Virtual scrolling for large lists (a11y-friendly)
class AccessibleVirtualList {
constructor(container, items, rowHeight = 50) {
this.container = container;
this.items = items;
this.rowHeight = rowHeight;
this.visibleCount = Math.ceil(container.clientHeight / rowHeight);
this.render();
}
render() {
const scrollTop = this.container.scrollTop;
const startIndex = Math.floor(scrollTop / this.rowHeight);
const endIndex = startIndex + this.visibleCount;
// Render only visible items
const visibleItems = this.items.slice(startIndex, endIndex);
// Maintain accessibility tree size
this.container.innerHTML = visibleItems.map((item, index) => `
<div
role="listitem"
aria-setsize="${this.items.length}"
aria-posinset="${startIndex + index + 1}"
>
${item.content}
</div>
`).join('');
// Set container ARIA attributes
this.container.setAttribute('role', 'list');
this.container.setAttribute('aria-label',
`List with ${this.items.length} items`
);
}
}
// Use requestIdleCallback for non-critical updates
function enhanceAccessibility() {
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
// Add ARIA enhancements during idle time
addAriaLabels();
addKeyboardHandlers();
}, { timeout: 2000 });
} else {
// Fallback
setTimeout(() => {
addAriaLabels();
addKeyboardHandlers();
}, 100);
}
}
// Clean up event listeners to prevent memory leaks
class AccessibleComponent {
constructor(element) {
this.element = element;
this.controller = new AbortController();
this.setupEventListeners();
}
setupEventListeners() {
// All listeners use same AbortController
this.element.addEventListener('click', this.handleClick, {
signal: this.controller.signal
});
this.element.addEventListener('keydown', this.handleKeydown, {
signal: this.controller.signal
});
}
handleClick = () => { /* ... */ };
handleKeydown = () => { /* ... */ };
destroy() {
// Remove all listeners at once
this.controller.abort();
}
}
// Monitor accessibility tree size
function checkAccessibilityTreeSize() {
const allElements = document.querySelectorAll('*');
const interactiveElements = document.querySelectorAll(
'button, a, input, select, textarea, [tabindex], [role]'
);
console.log('Total DOM nodes:', allElements.length);
console.log('Interactive/ARIA nodes:', interactiveElements.length);
if (interactiveElements.length > 1500) {
console.warn('Large accessibility tree - consider virtualization');
}
}
// Performance measurement
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === 'measure') {
console.log(`${entry.name}: ${entry.duration}ms`);
}
}
});
observer.observe({ entryTypes: ['measure'] });
performance.mark('aria-update-start');
// ... ARIA updates ...
performance.mark('aria-update-end');
performance.measure('ARIA Update Time', 'aria-update-start', 'aria-update-end');
Performance Pitfalls: Excessive ARIA updates cause screen reader lag. Large accessibility trees
(>1500 nodes) slow down AT navigation. Rapid live region changes overwhelm users. Always test performance
with screen readers enabled. Use Chrome DevTools Accessibility Tree to count nodes.
Performance and Accessibility Quick Reference
- Lazy Loading: Use loading="lazy" with alt text; announce loading states with aria-live; provide "Load more" alternative for infinite scroll
- Progressive Enhancement: Start with semantic HTML; enhance with CSS/JS; ensure core functionality works without JavaScript
- Critical Path: Inline critical CSS including focus styles; defer non-critical JS; preload fonts to prevent reflow
- Bundle Optimization: Tree-shake a11y libraries; use native features (dialog, details); strip axe-core from production
- Runtime Performance: Debounce live region updates; virtualize long lists; keep accessibility tree <1500 nodes; batch DOM updates
- Measurements: Lighthouse accessibility score; count accessibility tree nodes; monitor Total Blocking Time (TBT); test with AT enabled
- Service Workers: Cache critical accessibility features for offline access; ensure skip links work without JS
- Memory: Clean up event listeners with AbortController; avoid ARIA attribute thrashing; prevent memory leaks in SPAs
- Best Practices: Test performance with screen readers enabled; measure real-world AT performance; optimize for slow connections
- Tools: Lighthouse CI, webpack-bundle-analyzer, Chrome DevTools Performance, Accessibility Tree inspector
14. Framework-Specific Accessibility
14.1 React Accessibility Patterns
| Pattern | React Approach | Common Pitfalls | Best Practice |
|---|---|---|---|
| Fragment (<React.Fragment>) | Use to avoid unnecessary wrapper divs | Breaks accessibility when landmarks needed | Use semantic elements or fragments; don't wrap landmarks in divs |
| Refs for focus management | useRef + useEffect to manage focus | Focus set too early (before render) | Set focus in useEffect after component mounts/updates |
| Event handlers | onClick works for keyboard (Enter/Space) | Using onMouseDown/onMouseUp only | Use onClick on buttons; it handles keyboard automatically |
| ARIA attributes | Use camelCase: aria-label → aria-label (exception) | Using ariaLabel instead of aria-label | ARIA attributes stay lowercase with hyphens in JSX |
| Live regions | Create with useRef, update with state | Creating/destroying live regions on every update | Create once on mount, update content only |
| Dynamic content | Use keys for list items | Using index as key in dynamic lists | Use stable unique IDs; index only for static lists |
| Forms | Controlled components with labels | Missing label association | Use htmlFor on labels or wrap input in label |
| Portals | ReactDOM.createPortal for modals | Focus trap broken in portals | Manage focus explicitly; use focus-trap-react |
Example: React accessibility patterns
import { useRef, useEffect, useState } from 'react';
// Focus management with refs
function AccessibleModal({ isOpen, onClose, children }) {
const dialogRef = useRef(null);
const previousFocusRef = useRef(null);
useEffect(() => {
if (isOpen) {
// Store previous focus
previousFocusRef.current = document.activeElement;
// Focus dialog
dialogRef.current?.focus();
} else if (previousFocusRef.current) {
// Restore focus when closed
previousFocusRef.current.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
tabIndex={-1}
>
<h2 id="dialog-title">Modal Title</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
);
}
// Live region announcer hook
function useAnnouncer() {
const announcerRef = useRef(null);
useEffect(() => {
// Create live region once on mount
if (!announcerRef.current) {
const div = document.createElement('div');
div.setAttribute('role', 'status');
div.setAttribute('aria-live', 'polite');
div.setAttribute('aria-atomic', 'true');
div.className = 'sr-only';
document.body.appendChild(div);
announcerRef.current = div;
}
return () => {
// Cleanup on unmount
announcerRef.current?.remove();
};
}, []);
const announce = (message) => {
if (announcerRef.current) {
announcerRef.current.textContent = '';
setTimeout(() => {
announcerRef.current.textContent = message;
}, 100);
}
};
return announce;
}
// Usage
function ShoppingCart() {
const [items, setItems] = useState([]);
const announce = useAnnouncer();
const addItem = (item) => {
setItems([...items, item]);
announce(`${item.name} added to cart. ${items.length + 1} items total.`);
};
return (
<div>
<h2>Shopping Cart</h2>
{/* Cart content */}
</div>
);
}
// Accessible form with proper labels
function ContactForm() {
const [formData, setFormData] = useState({ name: '', email: '' });
const [errors, setErrors] = useState({});
const handleSubmit = (e) => {
e.preventDefault();
// Validation logic
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="name">Name</label>
<input
id="name"
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
aria-required="true"
aria-invalid={!!errors.name}
aria-describedby={errors.name ? 'name-error' : undefined}
/>
{errors.name && (
<span id="name-error" role="alert">
{errors.name}
</span>
)}
</div>
<button type="submit">Submit</button>
</form>
);
}
// Accessible list with proper keys
function ItemList({ items }) {
return (
<ul role="list">
{items.map((item) => (
<li key={item.id} role="listitem">
{item.name}
</li>
))}
</ul>
);
}
// React Router - accessible route changes
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
function RouteAnnouncer() {
const location = useLocation();
const announce = useAnnouncer();
useEffect(() => {
// Announce page changes
const title = document.title;
announce(`Navigated to ${title}`);
// Focus main heading
const heading = document.querySelector('h1');
if (heading) {
heading.setAttribute('tabindex', '-1');
heading.focus();
}
}, [location, announce]);
return null;
}
React A11y Tools: eslint-plugin-jsx-a11y, @axe-core/react, react-aria hooks, @reach/ui
components, Testing Library with accessible queries (getByRole, getByLabelText). Always test with screen
readers.
14.2 Vue.js Accessibility Implementation
| Vue Feature | Accessibility Usage | Example | Common Issues |
|---|---|---|---|
| Template refs | Access DOM for focus management | ref="inputRef" then this.$refs.inputRef.focus() |
Refs null until mounted |
| v-bind for ARIA | Bind ARIA attributes dynamically | :aria-expanded="isOpen" |
Vue 2 required .prop modifier for some ARIA |
| Teleport (Vue 3) | Move modals to body while maintaining component state | <Teleport to="body"> |
Focus trap needs manual management |
| Transition component | Announce state changes with ARIA live | Use @after-enter hook to announce | Animation-only, no automatic announcements |
| v-model on custom inputs | Two-way binding for accessible forms | v-model="formData.email" |
Need to emit 'update:modelValue' in Vue 3 |
| Watchers | Announce changes to screen readers | Watch data, update live region | Can cause announcement spam if not debounced |
| Directives | Create custom v-focus, v-trap-focus directives | Reusable focus management logic | Lifecycle hooks need proper cleanup |
Example: Vue.js accessibility patterns
<!-- Vue 3 Composition API -->
<template>
<div>
<button
@click="toggleDialog"
:aria-expanded="isDialogOpen"
aria-controls="dialog"
>
Open Dialog
</button>
<Teleport to="body">
<div
v-if="isDialogOpen"
ref="dialogRef"
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
tabindex="-1"
@keydown.esc="closeDialog"
>
<h2 id="dialog-title">Dialog Title</h2>
<button @click="closeDialog">Close</button>
</div>
</Teleport>
</div>
</template>
<script setup>
import { ref, watch, onMounted, nextTick } from 'vue';
const isDialogOpen = ref(false);
const dialogRef = ref(null);
let previousFocus = null;
const toggleDialog = () => {
isDialogOpen.value = !isDialogOpen.value;
};
const closeDialog = () => {
isDialogOpen.value = false;
};
// Focus management
watch(isDialogOpen, async (newValue) => {
if (newValue) {
previousFocus = document.activeElement;
await nextTick();
dialogRef.value?.focus();
} else if (previousFocus) {
previousFocus.focus();
}
});
</script>
<!-- Accessible form component -->
<template>
<form @submit.prevent="handleSubmit">
<div>
<label :for="inputId">{{ label }}</label>
<input
:id="inputId"
:type="type"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
:aria-required="required"
:aria-invalid="hasError"
:aria-describedby="hasError ? `${inputId}-error` : undefined"
/>
<span
v-if="hasError"
:id="`${inputId}-error`"
role="alert"
>
{{ errorMessage }}
</span>
</div>
</form>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: String,
label: String,
type: { type: String, default: 'text' },
required: Boolean,
errorMessage: String,
inputId: String
});
const emit = defineEmits(['update:modelValue']);
const hasError = computed(() => !!props.errorMessage);
</script>
<!-- Announcer composable -->
<script>
import { onMounted, onUnmounted } from 'vue';
export function useAnnouncer() {
let announcerElement = null;
onMounted(() => {
announcerElement = document.createElement('div');
announcerElement.setAttribute('role', 'status');
announcerElement.setAttribute('aria-live', 'polite');
announcerElement.setAttribute('aria-atomic', 'true');
announcerElement.className = 'sr-only';
document.body.appendChild(announcerElement);
});
onUnmounted(() => {
announcerElement?.remove();
});
const announce = (message) => {
if (announcerElement) {
announcerElement.textContent = '';
setTimeout(() => {
announcerElement.textContent = message;
}, 100);
}
};
return { announce };
}
</script>
<!-- Custom focus directive -->
<script>
export const vFocus = {
mounted(el) {
el.focus();
}
};
// Usage: <input v-focus />
</script>
<!-- Vue Router - route announcements -->
<script setup>
import { watch } from 'vue';
import { useRoute } from 'vue-router';
import { useAnnouncer } from '@/composables/useAnnouncer';
const route = useRoute();
const { announce } = useAnnouncer();
watch(() => route.path, () => {
// Announce route change
announce(`Navigated to ${route.meta.title || route.name}`);
// Focus main heading
const heading = document.querySelector('h1');
if (heading) {
heading.setAttribute('tabindex', '-1');
heading.focus();
}
});
</script>
Vue A11y Resources: vue-axe for development warnings, vue-announcer for screen reader
announcements, @vue-a11y/eslint-config-vue for linting. Use Vue DevTools to inspect component accessibility
tree.
14.3 Angular A11y Module Usage
| Angular CDK Feature | Purpose | Usage | Key Directives/Services |
|---|---|---|---|
| A11yModule | Core accessibility utilities | Import from @angular/cdk/a11y | Complete Angular CDK accessibility toolkit |
| LiveAnnouncer | ARIA live region announcements | Inject service, call announce() | Manages live region creation/updates |
| FocusTrap | Trap focus within element (modals) | cdkTrapFocus directive | Automatic focus cycling in dialogs |
| FocusMonitor | Track focus origin (keyboard/mouse/touch) | Inject service, monitor(element) | Different styling for keyboard vs mouse focus |
| ListKeyManager | Keyboard navigation in lists | ActiveDescendantKeyManager or FocusKeyManager | Arrow key navigation, Home/End support |
| cdkAriaLive | Declarative live regions | <div cdkAriaLive="polite"> |
Alternative to LiveAnnouncer service |
| InteractivityChecker | Check if element is focusable/visible | isFocusable(), isVisible() | Utility for focus management logic |
Example: Angular CDK accessibility features
// app.module.ts
import { A11yModule } from '@angular/cdk/a11y';
@NgModule({
imports: [A11yModule],
// ...
})
export class AppModule { }
// modal.component.ts
import { Component } from '@angular/core';
import { LiveAnnouncer } from '@angular/cdk/a11y';
@Component({
selector: 'app-modal',
template: `
<div
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
cdkTrapFocus
[cdkTrapFocusAutoCapture]="true"
>
<h2 id="dialog-title">Modal Title</h2>
<p>Modal content</p>
<button (click)="close()">Close</button>
</div>
`
})
export class ModalComponent {
constructor(private liveAnnouncer: LiveAnnouncer) {}
ngOnInit() {
this.liveAnnouncer.announce('Modal opened');
}
close() {
this.liveAnnouncer.announce('Modal closed');
// Close logic
}
}
// focus-monitor.component.ts
import { Component, ElementRef, OnInit, OnDestroy } from '@angular/core';
import { FocusMonitor, FocusOrigin } from '@angular/cdk/a11y';
@Component({
selector: 'app-button',
template: `
<button
#button
[class.keyboard-focus]="focusOrigin === 'keyboard'"
>
Click me
</button>
`
})
export class ButtonComponent implements OnInit, OnDestroy {
@ViewChild('button') buttonRef: ElementRef;
focusOrigin: FocusOrigin = null;
constructor(private focusMonitor: FocusMonitor) {}
ngOnInit() {
this.focusMonitor.monitor(this.buttonRef)
.subscribe(origin => {
this.focusOrigin = origin;
});
}
ngOnDestroy() {
this.focusMonitor.stopMonitoring(this.buttonRef);
}
}
// list-key-manager.component.ts
import { Component, QueryList, ViewChildren, AfterViewInit } from '@angular/core';
import { FocusKeyManager } from '@angular/cdk/a11y';
import { ListItemComponent } from './list-item.component';
@Component({
selector: 'app-list',
template: `
<ul
role="listbox"
(keydown)="onKeydown($event)"
>
<app-list-item
*ngFor="let item of items"
[item]="item"
role="option"
></app-list-item>
</ul>
`
})
export class ListComponent implements AfterViewInit {
@ViewChildren(ListItemComponent) listItems: QueryList<ListItemComponent>;
private keyManager: FocusKeyManager<ListItemComponent>;
items = ['Item 1', 'Item 2', 'Item 3'];
ngAfterViewInit() {
this.keyManager = new FocusKeyManager(this.listItems)
.withWrap()
.withHomeAndEnd();
}
onKeydown(event: KeyboardEvent) {
this.keyManager.onKeydown(event);
}
}
// list-item.component.ts
import { Component, Input, HostBinding } from '@angular/core';
import { FocusableOption } from '@angular/cdk/a11y';
@Component({
selector: 'app-list-item',
template: `<li>{{ item }}</li>`
})
export class ListItemComponent implements FocusableOption {
@Input() item: string;
@HostBinding('attr.tabindex') tabindex = '-1';
focus() {
// Focus logic
}
}
// Live region directive
@Component({
selector: 'app-cart',
template: `
<div>
<h2>Shopping Cart</h2>
<div cdkAriaLive="polite" cdkAriaAtomic="true">
{{ cartMessage }}
</div>
<button (click)="addItem()">Add Item</button>
</div>
`
})
export class CartComponent {
itemCount = 0;
cartMessage = '';
addItem() {
this.itemCount++;
this.cartMessage = `${this.itemCount} items in cart`;
}
}
// Router focus management
import { Router, NavigationEnd } from '@angular/router';
import { filter } from 'rxjs/operators';
@Component({
selector: 'app-root',
template: `<router-outlet></router-outlet>`
})
export class AppComponent implements OnInit {
constructor(
private router: Router,
private liveAnnouncer: LiveAnnouncer
) {}
ngOnInit() {
this.router.events
.pipe(filter(event => event instanceof NavigationEnd))
.subscribe(() => {
// Announce route change
const title = document.title;
this.liveAnnouncer.announce(`Navigated to ${title}`);
// Focus main heading
const heading = document.querySelector('h1') as HTMLElement;
if (heading) {
heading.setAttribute('tabindex', '-1');
heading.focus();
}
});
}
}
Angular A11y Best Practices: Use Angular CDK A11yModule for built-in accessibility. Material
components are pre-tested for accessibility. Use cdk-focus-monitor for keyboard-specific focus indicators. Test
with angular-axe or @axe-core/playwright.
14.4 Svelte Accessibility Features
| Svelte Feature | Accessibility Benefit | Usage | Compiler Warnings |
|---|---|---|---|
| A11y compiler warnings | Built-in accessibility linting | Automatic during compilation | Missing alt, label, ARIA issues detected |
| bind:this for refs | Direct DOM access for focus management | bind:this={element} |
Element available after mount |
| on: event handlers | onClick handles keyboard automatically | on:click={handler} |
Works for keyboard on buttons/links |
| Reactive statements ($:) | Update ARIA attributes reactively | $: ariaExpanded = isOpen |
Auto-updates when dependencies change |
| Transitions | Respect prefers-reduced-motion | Use @media query or matchMedia | No automatic reduced-motion handling |
| Slots | Flexible accessible component composition | <slot name="label"> |
Better than render props for semantics |
| Actions | Reusable focus trap, click-outside directives | use:focusTrap |
Custom accessibility behaviors |
Example: Svelte accessibility patterns
<!-- Modal.svelte -->
<script>
import { onMount, onDestroy } from 'svelte';
import { fly } from 'svelte/transition';
export let isOpen = false;
export let title = '';
let dialogElement;
let previousFocus;
$: if (isOpen && dialogElement) {
previousFocus = document.activeElement;
dialogElement.focus();
} else if (!isOpen && previousFocus) {
previousFocus.focus();
}
function handleKeydown(event) {
if (event.key === 'Escape') {
isOpen = false;
}
}
// Check for reduced motion preference
let prefersReducedMotion = false;
onMount(() => {
const query = window.matchMedia('(prefers-reduced-motion: reduce)');
prefersReducedMotion = query.matches;
});
</script>
{#if isOpen}
<div
bind:this={dialogElement}
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
tabindex="-1"
on:keydown={handleKeydown}
transition:fly={{ y: 200, duration: prefersReducedMotion ? 0 : 300 }}
>
<h2 id="dialog-title">{title}</h2>
<slot />
<button on:click={() => isOpen = false}>Close</button>
</div>
{/if}
<!-- Accessible Input Component -->
<script>
export let id;
export let label;
export let value = '';
export let error = '';
export let required = false;
$: hasError = !!error;
$: errorId = `${id}-error`;
</script>
<div>
<label for={id}>{label}</label>
<input
{id}
bind:value
aria-required={required}
aria-invalid={hasError}
aria-describedby={hasError ? errorId : undefined}
/>
{#if hasError}
<span id={errorId} role="alert">
{error}
</span>
{/if}
</div>
<!-- Live region announcer store -->
<script context="module">
import { writable } from 'svelte/store';
export const announcements = writable('');
export function announce(message) {
announcements.set('');
setTimeout(() => {
announcements.set(message);
}, 100);
}
</script>
<!-- Announcer.svelte (add to layout) -->
<script>
import { announcements } from './announcer.js';
</script>
<div
role="status"
aria-live="polite"
aria-atomic="true"
class="sr-only"
>
{$announcements}
</div>
<!-- Focus trap action -->
<script>
// focusTrap.js
export function focusTrap(node) {
const focusableElements = node.querySelectorAll(
'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
function handleKeydown(event) {
if (event.key !== 'Tab') return;
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
} else if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
node.addEventListener('keydown', handleKeydown);
return {
destroy() {
node.removeEventListener('keydown', handleKeydown);
}
};
}
</script>
<!-- Usage -->
<div use:focusTrap>
<!-- Focusable content -->
</div>
<!-- SvelteKit route announcements -->
<script>
import { page } from '$app/stores';
import { announce } from './announcer.js';
$: {
// Announce route changes
announce(`Navigated to ${$page.data.title || 'new page'}`);
// Focus main heading
const heading = document.querySelector('h1');
if (heading) {
heading.setAttribute('tabindex', '-1');
heading.focus();
}
}
</script>
Svelte A11y Advantages: Built-in compiler warnings for accessibility issues (a11y-*). No
runtime overhead for warnings. Actions provide elegant reusable accessibility patterns. Reactive statements
simplify ARIA updates. Use svelte-check and eslint-plugin-svelte for enhanced linting.
14.5 Web Components and Shadow DOM
| Challenge | Problem | Solution | Browser Support |
|---|---|---|---|
| ARIA across shadow boundary | aria-labelledby/describedby don't work across shadow DOM | Use slots or duplicate IDs in shadow, or use ElementInternals | Fundamental limitation |
| Form association | Custom inputs not recognized by forms | Use ElementInternals API with formAssociated: true | Chrome 77+, Safari 16.4+, Firefox 93+ |
| Focus delegation | Clicking custom element doesn't focus internal input | Use delegatesFocus: true in attachShadow | All modern browsers |
| Slots and semantics | Slotted content loses semantic context | Preserve semantic wrappers; use role attributes | Design consideration |
| CSS inheritance | Focus indicators may not inherit from global styles | Use CSS custom properties or :host-context | :host-context limited support |
| Screen reader testing | Inconsistent behavior across screen readers | Test extensively; prefer semantic HTML over ARIA | Testing requirement |
Example: Accessible web components
// Form-associated custom element
class AccessibleInput extends HTMLElement {
static formAssociated = true;
static observedAttributes = ['aria-label', 'aria-required'];
constructor() {
super();
this._internals = this.attachInternals();
// Create shadow DOM with focus delegation
const shadow = this.attachShadow({
mode: 'open',
delegatesFocus: true
});
shadow.innerHTML = `
<style>
:host {
display: inline-block;
}
input {
padding: 8px;
border: 1px solid var(--input-border, #ccc);
border-radius: 4px;
}
input:focus {
outline: 2px solid var(--focus-color, #0066cc);
outline-offset: 2px;
}
:host([aria-invalid="true"]) input {
border-color: var(--error-color, #d32f2f);
}
</style>
<input type="text" id="input" />
`;
this._input = shadow.querySelector('input');
this._setupEventListeners();
this._syncARIA();
}
_setupEventListeners() {
this._input.addEventListener('input', () => {
this._internals.setFormValue(this._input.value);
this.dispatchEvent(new Event('input', { bubbles: true }));
});
}
_syncARIA() {
// Forward ARIA attributes from host to input
const observer = new MutationObserver(() => {
this._updateInputARIA();
});
observer.observe(this, {
attributes: true,
attributeFilter: ['aria-label', 'aria-required', 'aria-invalid']
});
this._updateInputARIA();
}
_updateInputARIA() {
['aria-label', 'aria-required', 'aria-invalid'].forEach(attr => {
const value = this.getAttribute(attr);
if (value) {
this._input.setAttribute(attr, value);
} else {
this._input.removeAttribute(attr);
}
});
}
// Expose value for forms
get value() {
return this._input.value;
}
set value(val) {
this._input.value = val;
this._internals.setFormValue(val);
}
// Form validation
checkValidity() {
return this._internals.checkValidity();
}
reportValidity() {
return this._internals.reportValidity();
}
}
customElements.define('accessible-input', AccessibleInput);
// Usage
/*
<form>
<label for="email">Email</label>
<accessible-input
name="email"
aria-label="Email address"
aria-required="true"
></accessible-input>
</form>
*/
// Button component with proper semantics
class AccessibleButton extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({
mode: 'open',
delegatesFocus: true
});
shadow.innerHTML = `
<style>
button {
padding: 12px 24px;
background: var(--button-bg, #0066cc);
color: var(--button-color, white);
border: none;
border-radius: 4px;
cursor: pointer;
font: inherit;
}
button:hover {
background: var(--button-hover-bg, #0052a3);
}
button:focus-visible {
outline: 2px solid var(--focus-color, #0066cc);
outline-offset: 2px;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
<button part="button">
<slot></slot>
</button>
`;
this._button = shadow.querySelector('button');
// Forward click events
this._button.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('button-click', {
bubbles: true,
composed: true
}));
});
}
static observedAttributes = ['disabled', 'aria-pressed'];
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'disabled') {
this._button.disabled = newValue !== null;
} else if (name === 'aria-pressed') {
this._button.setAttribute('aria-pressed', newValue);
}
}
}
customElements.define('accessible-button', AccessibleButton);
// Accordion with proper ARIA
class AccessibleAccordion extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._isOpen = false;
}
connectedCallback() {
this.render();
this._setupEventListeners();
}
render() {
this.shadowRoot.innerHTML = `
<style>
button {
width: 100%;
padding: 16px;
text-align: left;
background: #f5f5f5;
border: 1px solid #ddd;
cursor: pointer;
}
.content {
padding: 16px;
border: 1px solid #ddd;
border-top: none;
display: none;
}
.content[aria-hidden="false"] {
display: block;
}
</style>
<button
aria-expanded="${this._isOpen}"
aria-controls="content"
>
<slot name="header"></slot>
</button>
<div
id="content"
role="region"
aria-hidden="${!this._isOpen}"
>
<slot name="content"></slot>
</div>
`;
}
_setupEventListeners() {
const button = this.shadowRoot.querySelector('button');
button.addEventListener('click', () => {
this.toggle();
});
}
toggle() {
this._isOpen = !this._isOpen;
this.render();
}
}
customElements.define('accessible-accordion', AccessibleAccordion);
// Usage
/*
<accessible-accordion>
<span slot="header">Click to expand</span>
<div slot="content">Hidden content here</div>
</accessible-accordion>
*/
Web Component A11y Challenges: ARIA references (aria-labelledby) don't cross shadow boundaries
- use slots or ElementInternals. Always set delegatesFocus: true for form controls. Test thoroughly with screen
readers (behavior varies). Use native elements inside shadow DOM when possible.
Framework-Specific Accessibility Quick Reference
- React: Use refs for focus management; create live regions once on mount; eslint-plugin-jsx-a11y for linting; Testing Library for accessible queries
- Vue: Template refs for DOM access; Teleport for modals; watch() for announcements; vue-axe for dev warnings; composables for reusable a11y logic
- Angular: CDK A11yModule (LiveAnnouncer, FocusTrap, FocusMonitor); ListKeyManager for keyboard navigation; Material components pre-tested
- Svelte: Built-in compiler warnings; bind:this for refs; actions for reusable behaviors; reactive statements for ARIA; svelte-check for validation
- Web Components: Use delegatesFocus: true; ElementInternals for forms; forward ARIA from host to shadow; slots for flexible semantics
- Common Patterns: Focus management on route changes; live region announcements; keyboard event handling; form validation feedback
- Testing: Framework-specific test libraries support accessible queries; test with screen readers; validate ARIA implementation
- Libraries: react-aria, @reach/ui (React); vue-announcer (Vue); Angular CDK; focus-trap libraries; polyfills for older browsers
- Best Practices: Start with semantic HTML; enhance with ARIA; test with keyboard only; verify screen reader announcements
- Tools: ESLint plugins, axe-core integrations, framework DevTools, browser accessibility inspectors
15. Advanced ARIA Techniques
15.1 Complex Widget Patterns
| Widget Pattern | Required Roles/Attributes | Keyboard Behavior | Use Case |
|---|---|---|---|
| Menu/Menubar | role="menubar", role="menuitem", aria-haspopup,
aria-expanded
|
Arrow keys navigate, Enter/Space activate, Esc closes, Home/End to first/last | Application menus, context menus, navigation menus |
| Toolbar | role="toolbar", aria-label, aria-orientation |
Arrow keys navigate, Tab leaves toolbar, Home/End to first/last | Rich text editors, formatting controls, action groups |
| Slider (Range) | role="slider", aria-valuemin, aria-valuemax,
aria-valuenow, aria-valuetext
|
Arrow Up/Right increase, Arrow Down/Left decrease, Home/End to min/max, Page Up/Down for larger steps | Volume controls, price ranges, rating systems |
| Spinbutton | role="spinbutton", aria-valuemin, aria-valuemax,
aria-valuenow
|
Arrow Up/Down adjust value, Page Up/Down for larger steps, Home/End to min/max | Numeric input with increment/decrement buttons |
| Feed | role="feed", role="article", aria-setsize,
aria-posinset, aria-busy
|
Page Down/Up navigate articles, specific screen reader commands for feed navigation | Infinite scroll, social media feeds, news streams |
| Carousel | role="region", aria-label, aria-live="polite" (for
auto-rotation), aria-roledescription="carousel" |
Previous/Next buttons, pause/play control, optional arrow key navigation | Image galleries, featured content, product showcases |
Example: Accessible Menubar Implementation
<nav role="menubar" aria-label="Main Navigation">
<div role="menuitem" aria-haspopup="true" aria-expanded="false" tabindex="0">
File
<div role="menu" aria-label="File Menu" hidden>
<div role="menuitem" tabindex="-1">New</div>
<div role="menuitem" tabindex="-1">Open</div>
<div role="separator"></div>
<div role="menuitem" tabindex="-1">Save</div>
</div>
</div>
</nav>
<script>
// Keyboard navigation handler
menubar.addEventListener('keydown', (e) => {
switch(e.key) {
case 'ArrowRight': focusNextMenuItem(); break;
case 'ArrowLeft': focusPrevMenuItem(); break;
case 'Enter':
case ' ': toggleSubmenu(); break;
case 'Escape': closeSubmenu(); break;
}
});
</script>
Example: Multi-thumb Slider Pattern
<div role="group" aria-labelledby="price-label">
<span id="price-label">Price Range</span>
<div class="slider-track">
<div role="slider"
aria-label="Minimum price"
aria-valuemin="0"
aria-valuemax="1000"
aria-valuenow="100"
aria-valuetext="$100"
tabindex="0"></div>
<div role="slider"
aria-label="Maximum price"
aria-valuemin="0"
aria-valuemax="1000"
aria-valuenow="500"
aria-valuetext="$500"
tabindex="0"></div>
</div>
<output aria-live="polite">$100 - $500</output>
</div>
Widget Pattern Best Practices:
- Follow ARIA Authoring Practices Guide (APG) specifications exactly
- Provide visible focus indicators for all interactive elements
- Include keyboard shortcuts documentation (aria-keyshortcuts)
- Test with multiple screen readers (NVDA, JAWS, VoiceOver, TalkBack)
- Provide escape hatches (Esc key, close buttons) for all overlays
- Use roving tabindex pattern for single-tab-stop widget groups
15.2 Virtual Focus Management
| Technique | Implementation | ARIA Attributes | Use Case |
|---|---|---|---|
| Active Descendant | aria-activedescendant points to focused child ID |
aria-activedescendant="item-id", container gets focus |
Listboxes, comboboxes, trees, grids where container maintains focus |
| Roving Tabindex | Only one focusable child (tabindex="0"), others tabindex="-1" | Update tabindex on arrow key navigation | Toolbars, menubars, tab lists, radio groups |
| Focus Trap | Cycle focus within container, prevent Tab from leaving | aria-modal="true", manage Tab/Shift+Tab events |
Modal dialogs, dropdown menus, overlays |
| Focus Restoration | Store reference to previous focus, restore on close | Use document.activeElement before opening widget |
Dialogs, drawers, any temporary widget |
| Programmatic Focus | element.focus() with optional preventScroll |
Set tabindex="-1" on non-interactive targets |
Skip links, error messages, dynamic content updates |
| Virtual Cursor | Custom focus indicator separate from browser focus | aria-activedescendant, visual styling only |
Spreadsheets, complex grids, custom navigation systems |
Example: Active Descendant Pattern (Listbox)
<div role="listbox"
aria-label="Color selection"
aria-activedescendant="option-red"
tabindex="0">
<div role="option" id="option-red" aria-selected="true">Red</div>
<div role="option" id="option-blue">Blue</div>
<div role="option" id="option-green">Green</div>
</div>
<script>
let activeIndex = 0;
const listbox = document.querySelector('[role="listbox"]');
const options = listbox.querySelectorAll('[role="option"]');
listbox.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') {
activeIndex = Math.min(activeIndex + 1, options.length - 1);
updateActiveDescendant();
} else if (e.key === 'ArrowUp') {
activeIndex = Math.max(activeIndex - 1, 0);
updateActiveDescendant();
}
});
function updateActiveDescendant() {
const activeOption = options[activeIndex];
listbox.setAttribute('aria-activedescendant', activeOption.id);
// Update visual indicator
options.forEach(opt => opt.classList.remove('focused'));
activeOption.classList.add('focused');
// Ensure visible (scroll into view)
activeOption.scrollIntoView({ block: 'nearest' });
}
</script>
Example: Focus Trap for Modal Dialog
function createFocusTrap(element) {
const focusableSelector = 'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])';
const focusable = element.querySelectorAll(focusableSelector);
const firstFocusable = focusable[0];
const lastFocusable = focusable[focusable.length - 1];
const previousFocus = document.activeElement;
element.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
if (e.shiftKey) {
if (document.activeElement === firstFocusable) {
e.preventDefault();
lastFocusable.focus();
}
} else {
if (document.activeElement === lastFocusable) {
e.preventDefault();
firstFocusable.focus();
}
}
}
});
// Initial focus
firstFocusable.focus();
// Cleanup function
return () => previousFocus.focus();
}
</script>
Virtual Focus Pitfalls: aria-activedescendant only works with specific roles (listbox, tree,
grid, combobox). Browser focus must remain on container. Screen readers may not announce changes without proper
role context. Always test with actual assistive technologies. Roving tabindex requires maintaining state
correctly. Focus trap must allow Esc key exit.
15.3 Multi-Select Components
| Component Type | ARIA Pattern | Selection Behavior | Keyboard Shortcuts |
|---|---|---|---|
| Multi-select Listbox | role="listbox", aria-multiselectable="true", aria-selected on
options |
Click/Space toggle, Ctrl+Click add to selection, Shift+Click range select | Ctrl+A select all, Space toggle current, Shift+Arrow range extend |
| Checkbox Group | role="group", aria-labelledby, native checkboxes |
Each checkbox independently toggleable | Tab between checkboxes, Space to toggle |
| Multi-select Combobox | role="combobox", aria-multiselectable="true",
aria-activedescendant
|
Type to filter, Arrow to navigate, Enter to add to selection | Type for autocomplete, Enter add, Backspace remove last, Esc close |
| Transfer List | Two listboxes with buttons, aria-labelledby for associations |
Select in source list, button moves to destination | Arrow navigate, Space select, Tab to buttons, Enter to transfer |
| Tag Input | role="listbox" for tags, role="option" for each tag |
Type to add, click X or Backspace to remove | Backspace removes last tag, Arrow keys navigate tags, Delete removes focused tag |
Example: Multi-select Listbox with Range Selection
<div role="listbox"
aria-label="Select multiple items"
aria-multiselectable="true"
tabindex="0">
<div role="option" aria-selected="false" id="opt-1">Option 1</div>
<div role="option" aria-selected="false" id="opt-2">Option 2</div>
<div role="option" aria-selected="false" id="opt-3">Option 3</div>
<div role="option" aria-selected="false" id="opt-4">Option 4</div>
</div>
<output aria-live="polite" aria-atomic="true"></output>
<script>
let selectedIndices = new Set();
let lastSelectedIndex = -1;
function toggleSelection(index, rangeSelect = false) {
const options = listbox.querySelectorAll('[role="option"]');
if (rangeSelect && lastSelectedIndex !== -1) {
// Range selection with Shift
const start = Math.min(lastSelectedIndex, index);
const end = Math.max(lastSelectedIndex, index);
for (let i = start; i <= end; i++) {
selectedIndices.add(i);
options[i].setAttribute('aria-selected', 'true');
}
} else {
// Toggle single selection
if (selectedIndices.has(index)) {
selectedIndices.delete(index);
options[index].setAttribute('aria-selected', 'false');
} else {
selectedIndices.add(index);
options[index].setAttribute('aria-selected', 'true');
}
lastSelectedIndex = index;
}
announceSelection();
}
function announceSelection() {
const count = selectedIndices.size;
document.querySelector('output').textContent =
`${count} item${count !== 1 ? 's' : ''} selected`;
}
</script>
Example: Tag Input Component
<div class="tag-input" role="group" aria-labelledby="tag-label">
<span id="tag-label">Add tags:</span>
<ul role="listbox" aria-label="Selected tags">
<li role="option">
JavaScript
<button aria-label="Remove JavaScript tag">×</button>
</li>
<li role="option">
React
<button aria-label="Remove React tag">×</button>
</li>
</ul>
<input type="text"
aria-label="Add new tag"
aria-autocomplete="list"
aria-controls="tag-suggestions">
</div>
<div id="tag-suggestions" role="listbox" hidden>
<div role="option">TypeScript</div>
<div role="option">Node.js</div>
</div>
Multi-select Best Practices:
- Announce selection count changes via live region
- Provide "Select All" and "Clear Selection" actions
- Visually distinguish selected items (checked, highlighted)
- Support both mouse and keyboard selection methods
- Document keyboard shortcuts in help text or tooltip
- Consider mobile: provide checkboxes instead of Ctrl+Click
15.4 Data Grid Implementation
| Grid Feature | ARIA Implementation | Keyboard Navigation | Screen Reader Behavior |
|---|---|---|---|
| Basic Grid | role="grid", role="row", role="columnheader",
role="gridcell"
|
Arrow keys navigate cells, Home/End row start/end, Ctrl+Home/End grid corners | Announces row/column headers, position (e.g., "row 2 of 10, column 3 of 5") |
| Sortable Columns | aria-sort="ascending|descending|none" on column headers |
Enter/Space on column header to sort | Announces "sorted ascending/descending" when sort changes |
| Editable Cells | aria-readonly="false", Enter to edit mode, Esc to cancel |
Enter/F2 enter edit mode, Esc cancel, Tab/Arrow exit and save | Announces "editable" or "read-only" for each cell |
| Row Selection | aria-selected="true" on row, aria-multiselectable on grid |
Click or Space on row to select, Ctrl+Click multi-select, Shift+Arrow range | Announces "selected" state change |
| Expandable Rows | aria-expanded on row, aria-level for hierarchy,
aria-setsize/aria-posinset
|
Arrow Right expands, Arrow Left collapses, * expands all siblings | Announces expanded/collapsed state and nesting level |
| Column Spanning | aria-colspan, aria-rowspan on gridcell |
Navigation skips spanned cells appropriately | Announces spanning information (e.g., "spans 2 columns") |
Example: Accessible Data Grid with Sorting and Navigation
<div role="grid" aria-label="Product inventory" aria-rowcount="100">
<div role="rowgroup">
<div role="row" aria-rowindex="1">
<span role="columnheader" aria-sort="ascending" aria-colindex="1">
<button>Product Name</button>
</span>
<span role="columnheader" aria-sort="none" aria-colindex="2">
<button>Price</button>
</span>
<span role="columnheader" aria-sort="none" aria-colindex="3">
<button>Stock</button>
</span>
</div>
</div>
<div role="rowgroup">
<div role="row" aria-rowindex="2">
<span role="gridcell" aria-colindex="1" tabindex="0">Widget A</span>
<span role="gridcell" aria-colindex="2" tabindex="-1">$29.99</span>
<span role="gridcell" aria-colindex="3" tabindex="-1">42</span>
</div>
<div role="row" aria-rowindex="3">
<span role="gridcell" aria-colindex="1" tabindex="-1">Widget B</span>
<span role="gridcell" aria-colindex="2" tabindex="-1">$39.99</span>
<span role="gridcell" aria-colindex="3" tabindex="-1">17</span>
</div>
</div>
</div>
<script>
// Arrow key navigation
grid.addEventListener('keydown', (e) => {
const cell = document.activeElement.closest('[role="gridcell"]');
if (!cell) return;
let targetCell;
switch(e.key) {
case 'ArrowRight': targetCell = getNextCell(cell); break;
case 'ArrowLeft': targetCell = getPrevCell(cell); break;
case 'ArrowDown': targetCell = getCellBelow(cell); break;
case 'ArrowUp': targetCell = getCellAbove(cell); break;
case 'Home':
targetCell = e.ctrlKey ? getFirstCell() : getFirstCellInRow(cell);
break;
case 'End':
targetCell = e.ctrlKey ? getLastCell() : getLastCellInRow(cell);
break;
}
if (targetCell) {
e.preventDefault();
targetCell.focus();
}
});
</script>
Example: Editable Grid Cell Pattern
<span role="gridcell" tabindex="0" aria-readonly="false">
<span class="cell-content">Original Value</span>
<input type="text" class="cell-editor" hidden>
</span>
<script>
function enterEditMode(cell) {
const content = cell.querySelector('.cell-content');
const editor = cell.querySelector('.cell-editor');
editor.value = content.textContent;
content.hidden = true;
editor.hidden = false;
editor.focus();
cell.setAttribute('aria-readonly', 'false');
}
function exitEditMode(cell, save = true) {
const content = cell.querySelector('.cell-content');
const editor = cell.querySelector('.cell-editor');
if (save) {
content.textContent = editor.value;
}
content.hidden = false;
editor.hidden = true;
cell.focus();
}
gridcell.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === 'F2') {
enterEditMode(gridcell);
}
});
editor.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
exitEditMode(gridcell, false);
} else if (e.key === 'Enter') {
exitEditMode(gridcell, true);
}
});
</script>
Data Grid Complexity: Full grid implementation is one of the most complex ARIA patterns.
Consider using established libraries (ag-Grid, TanStack Table) with built-in accessibility. Virtualized grids
require aria-rowcount/aria-rowindex. Large grids may overwhelm screen readers - provide filtering/search. Test
extensively with keyboard only and screen readers.
15.5 Tree View and Hierarchical Data
| Tree Feature | ARIA Attributes | Keyboard Behavior | Implementation Notes |
|---|---|---|---|
| Tree Container | role="tree", aria-label or aria-labelledby |
Tab enters tree, Arrow keys navigate, Enter activates | Single tab stop for entire tree (roving tabindex on items) |
| Tree Items | role="treeitem", aria-level, aria-setsize,
aria-posinset
|
First child has tabindex="0", others tabindex="-1" | Level starts at 1 for root items, increments for children |
| Expandable Nodes | aria-expanded="true|false", role="group" for children |
Arrow Right expands, Arrow Left collapses, * expands all siblings | Hide children when collapsed (display: none or hidden) |
| Selection | aria-selected="true|false", aria-multiselectable on tree |
Space selects/deselects, Ctrl+Space multi-select, Shift+Arrow range | Visual indicator required for selected state |
| Navigation | Focus management with roving tabindex | Arrow Up/Down traverse visible items, Home/End first/last item | Skip collapsed children when navigating |
| Multi-select Tree | aria-multiselectable="true" on tree |
Ctrl+Arrow move focus without selection, Ctrl+Space toggle selection | Separate focus from selection state |
Example: File System Tree View
<div role="tree" aria-label="File system" aria-multiselectable="false">
<div role="treeitem"
aria-expanded="true"
aria-level="1"
aria-setsize="2"
aria-posinset="1"
tabindex="0">
<span>📁 Documents</span>
<div role="group">
<div role="treeitem"
aria-level="2"
aria-setsize="2"
aria-posinset="1"
tabindex="-1">
<span>📄 Report.pdf</span>
</div>
<div role="treeitem"
aria-expanded="false"
aria-level="2"
aria-setsize="2"
aria-posinset="2"
tabindex="-1">
<span>📁 Archives</span>
<div role="group" hidden>
<div role="treeitem" aria-level="3" tabindex="-1">
<span>📄 2023.zip</span>
</div>
</div>
</div>
</div>
</div>
<div role="treeitem"
aria-expanded="false"
aria-level="1"
aria-setsize="2"
aria-posinset="2"
tabindex="-1">
<span>📁 Pictures</span>
<div role="group" hidden></div>
</div>
</div>
<script>
class TreeView {
constructor(treeElement) {
this.tree = treeElement;
this.items = Array.from(treeElement.querySelectorAll('[role="treeitem"]'));
this.focusedItem = this.items.find(item => item.tabIndex === 0);
this.tree.addEventListener('keydown', this.handleKeydown.bind(this));
this.tree.addEventListener('click', this.handleClick.bind(this));
}
handleKeydown(e) {
const item = e.target.closest('[role="treeitem"]');
if (!item) return;
switch(e.key) {
case 'ArrowDown':
e.preventDefault();
this.focusNextVisible();
break;
case 'ArrowUp':
e.preventDefault();
this.focusPrevVisible();
break;
case 'ArrowRight':
e.preventDefault();
if (item.getAttribute('aria-expanded') === 'false') {
this.expand(item);
} else {
this.focusFirstChild(item);
}
break;
case 'ArrowLeft':
e.preventDefault();
if (item.getAttribute('aria-expanded') === 'true') {
this.collapse(item);
} else {
this.focusParent(item);
}
break;
case 'Home':
e.preventDefault();
this.focusFirst();
break;
case 'End':
e.preventDefault();
this.focusLast();
break;
case '*':
e.preventDefault();
this.expandAllSiblings(item);
break;
case 'Enter':
case ' ':
e.preventDefault();
this.toggleExpanded(item);
break;
}
}
expand(item) {
const group = item.querySelector('[role="group"]');
if (group) {
item.setAttribute('aria-expanded', 'true');
group.hidden = false;
}
}
collapse(item) {
const group = item.querySelector('[role="group"]');
if (group) {
item.setAttribute('aria-expanded', 'false');
group.hidden = true;
}
}
setFocused(item) {
if (this.focusedItem) {
this.focusedItem.tabIndex = -1;
}
item.tabIndex = 0;
item.focus();
this.focusedItem = item;
}
}
</script>
Tree View Best Practices:
- Announce tree structure: "level X of Y, item N of M"
- Provide visual expansion indicators (arrows, +/- icons)
- Consider lazy loading for large trees (update aria-setsize dynamically)
- Support type-ahead search: focus item starting with typed character
- Persist expanded/collapsed state across sessions when appropriate
- Test with screen readers - tree navigation varies by SR
15.6 Custom Live Region Strategies
| Strategy | Implementation | Use Case | Timing Considerations |
|---|---|---|---|
| Debounced Updates | Delay announcements until user stops typing/interacting | Search suggestions, form validation, character counters | 300-500ms delay typical, prevents announcement spam |
| Atomic Regions | aria-atomic="true" announces entire region even if partial update |
Status messages, scoreboard, timer displays | Use when context is lost with partial updates |
| Relevant Property | aria-relevant="additions|removals|text|all" controls what changes announce |
Chat messages (additions), todo lists (additions removals), counters (text) | Default is "additions text", be specific to reduce noise |
| Priority Queueing | Use aria-live="assertive" for urgent, polite for non-urgent |
Errors are assertive, status updates are polite | Assertive interrupts current speech, use sparingly |
| Progressive Disclosure | Announce summary first, provide details on demand | Long notifications, complex updates, batch operations | E.g., "5 items updated" vs reading all 5 items |
| Visual-Only Updates | Use aria-hidden="true" for decorative or redundant updates |
Loading spinners (when status text exists), progress bars with labels | Prevent double-announcement of same information |
Example: Debounced Live Region for Search Results
<label for="search">Search products</label>
<input type="search"
id="search"
aria-controls="results-status"
aria-describedby="results-status">
<div id="results-status"
role="status"
aria-live="polite"
aria-atomic="true">
</div>
<div id="results-list"></div>
<script>
let debounceTimer;
searchInput.addEventListener('input', (e) => {
clearTimeout(debounceTimer);
// Show loading state immediately (visual only)
resultsStatus.textContent = 'Searching...';
resultsStatus.setAttribute('aria-busy', 'true');
// Debounce the actual announcement
debounceTimer = setTimeout(async () => {
const query = e.target.value;
const results = await searchProducts(query);
// Update results
displayResults(results);
// Announce count (this triggers screen reader)
resultsStatus.setAttribute('aria-busy', 'false');
resultsStatus.textContent =
`${results.length} result${results.length !== 1 ? 's' : ''} found for "${query}"`;
}, 500);
});
</script>
Example: Custom Notification Queue System
<!-- Separate regions for different priority levels -->
<div role="alert" aria-live="assertive" class="sr-only"></div>
<div role="status" aria-live="polite" class="sr-only"></div>
<script>
class NotificationManager {
constructor() {
this.assertiveRegion = document.querySelector('[role="alert"]');
this.politeRegion = document.querySelector('[role="status"]');
this.queue = [];
this.isAnnouncing = false;
}
announce(message, priority = 'polite', delay = 0) {
setTimeout(() => {
if (priority === 'assertive') {
// Clear and announce immediately
this.assertiveRegion.textContent = '';
setTimeout(() => {
this.assertiveRegion.textContent = message;
}, 100);
} else {
// Queue polite announcements
this.queue.push(message);
this.processQueue();
}
}, delay);
}
async processQueue() {
if (this.isAnnouncing || this.queue.length === 0) return;
this.isAnnouncing = true;
const message = this.queue.shift();
// Clear region
this.politeRegion.textContent = '';
// Wait for screen reader to register the clear
await new Promise(resolve => setTimeout(resolve, 100));
// Announce message
this.politeRegion.textContent = message;
// Wait for announcement to complete
await new Promise(resolve => setTimeout(resolve, 2000));
this.isAnnouncing = false;
this.processQueue();
}
clear() {
this.queue = [];
this.assertiveRegion.textContent = '';
this.politeRegion.textContent = '';
}
}
// Usage
const notify = new NotificationManager();
notify.announce('Form saved successfully', 'polite');
notify.announce('Error: Connection lost', 'assertive');
</script>
Example: Progressive Disclosure for Batch Operations
<button id="delete-selected">Delete Selected Items</button>
<div role="status" aria-live="polite" aria-atomic="true"></div>
<script>
async function deleteMultipleItems(itemIds) {
const status = document.querySelector('[role="status"]');
const total = itemIds.length;
let completed = 0;
// Initial announcement - summary only
status.textContent = `Deleting ${total} items...`;
for (const id of itemIds) {
await deleteItem(id);
completed++;
// Progress updates every 25% or last item
if (completed % Math.ceil(total / 4) === 0 || completed === total) {
status.textContent =
completed === total
? `Successfully deleted ${total} items`
: `Deleted ${completed} of ${total} items`;
}
}
}
// Alternative: Provide details button
function deleteWithDetails(itemIds) {
const status = document.querySelector('[role="status"]');
status.innerHTML = `
Deleted ${itemIds.length} items.
<button onclick="showDeletedItems()">View details</button>
`;
}
</script>
Live Region Gotchas: Live regions must exist in DOM on page load (can be empty). Changes to
aria-live attribute itself are not announced. Rapid consecutive updates may be missed - use debouncing or
queueing. Screen readers may ignore updates to hidden elements. Test with actual screen readers - behavior
varies significantly. Use sparingly - too many announcements overwhelm users.
Advanced ARIA Techniques Summary
- Complex Widgets: Follow ARIA APG patterns exactly; provide keyboard shortcuts; test with multiple screen readers
- Virtual Focus: Use aria-activedescendant for list-like widgets; roving tabindex for toolbars/menus; always restore focus on close
- Multi-select: Support Ctrl+Click, Shift+range, keyboard shortcuts; announce selection count; provide clear/select all
- Data Grids: Most complex ARIA pattern; consider established libraries; provide row/column headers; support sorting and editing
- Tree Views: Use aria-level, aria-expanded, roving tabindex; support keyboard navigation; lazy load large trees
- Live Regions: Debounce rapid updates; use atomic for context; queue announcements; polite vs assertive by urgency
- Testing: Keyboard-only navigation first; test with NVDA, JAWS, VoiceOver, TalkBack; validate with axe/Lighthouse
- Performance: Virtual scrolling for large datasets; lazy loading with proper announcements; avoid excessive DOM updates
16. Browser APIs and Modern Features
16.1 Web Speech API Accessibility
| API Feature | Accessibility Consideration | Implementation Best Practice | WCAG Impact |
|---|---|---|---|
| Speech Recognition | Alternative input method for users with motor impairments | Provide keyboard/mouse alternatives; announce recognition status; handle errors gracefully | Supports WCAG 2.1.1 (Keyboard), 2.5.1 (Pointer Gestures) |
| Speech Synthesis | May conflict with screen readers; user needs control | Provide play/pause/stop controls; allow speed/volume adjustment; don't auto-play | WCAG 1.4.2 (Audio Control), 2.2.2 (Pause, Stop, Hide) |
| Voice Commands | Alternative navigation for keyboard/motor limitations | Document commands; provide visual feedback; support custom phrases | Supports multiple input modalities (WCAG Principle 2) |
| Language Support | Match user's language preferences | Use lang attribute; support multiple languages; respect browser language |
WCAG 3.1.1 (Language of Page), 3.1.2 (Language of Parts) |
| Permissions | Clear explanation needed for microphone access | Explain why permission needed; graceful degradation if denied; visual indicator when active | Privacy and user control (WCAG Principle 4) |
Example: Accessible Speech Recognition Implementation
<div class="voice-input">
<button id="start-recognition" aria-pressed="false">
<span class="icon" aria-hidden="true">🎤</span>
Start Voice Input
</button>
<div role="status" aria-live="polite" aria-atomic="true">
<span id="recognition-status"></span>
</div>
<label for="voice-transcript">Voice Transcript</label>
<textarea id="voice-transcript"
aria-describedby="voice-help"></textarea>
<p id="voice-help" class="help-text">
Click the microphone button and speak. You can also type directly.
</p>
</div>
<script>
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
// Graceful degradation
document.querySelector('.voice-input').insertAdjacentHTML('beforeend',
'<div class="note">Voice input not supported in this browser.</div>'
);
} else {
const recognition = new SpeechRecognition();
const button = document.getElementById('start-recognition');
const status = document.getElementById('recognition-status');
const transcript = document.getElementById('voice-transcript');
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = document.documentElement.lang || 'en-US';
let isListening = false;
button.addEventListener('click', () => {
if (isListening) {
recognition.stop();
isListening = false;
button.setAttribute('aria-pressed', 'false');
button.textContent = 'Start Voice Input';
status.textContent = 'Voice input stopped';
} else {
recognition.start();
isListening = true;
button.setAttribute('aria-pressed', 'true');
button.textContent = 'Stop Voice Input';
status.textContent = 'Listening...';
}
});
recognition.onresult = (event) => {
let interimTranscript = '';
let finalTranscript = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
const result = event.results[i];
if (result.isFinal) {
finalTranscript += result[0].transcript + ' ';
} else {
interimTranscript += result[0].transcript;
}
}
transcript.value += finalTranscript;
// Update status with interim results (visual only, not announced)
status.setAttribute('aria-live', 'off');
status.textContent = interimTranscript || 'Listening...';
status.setAttribute('aria-live', 'polite');
};
recognition.onerror = (event) => {
status.textContent = `Error: ${event.error}. Please try again.`;
isListening = false;
button.setAttribute('aria-pressed', 'false');
};
}
</script>
Example: Accessible Text-to-Speech with Controls
<div class="tts-player">
<button id="play-tts" aria-label="Read text aloud">▶ Play</button>
<button id="pause-tts" aria-label="Pause reading" disabled>⏸ Pause</button>
<button id="stop-tts" aria-label="Stop reading" disabled>⏹ Stop</button>
<div class="controls">
<label for="tts-rate">Speed: <span id="rate-value">1x</span></label>
<input type="range"
id="tts-rate"
min="0.5"
max="2"
step="0.1"
value="1"
aria-valuemin="0.5"
aria-valuemax="2"
aria-valuenow="1"
aria-valuetext="1x speed">
<label for="tts-volume">Volume: <span id="volume-value">100%</span></label>
<input type="range"
id="tts-volume"
min="0"
max="1"
step="0.1"
value="1"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="100"
aria-valuetext="100%">
</div>
<div role="status" aria-live="polite"><span id="tts-status"></span></div>
</div>
<script>
const synth = window.speechSynthesis;
let utterance = null;
let isPaused = false;
document.getElementById('play-tts').addEventListener('click', () => {
if (isPaused) {
synth.resume();
updateStatus('Reading resumed');
} else {
const text = document.querySelector('article').textContent;
utterance = new SpeechSynthesisUtterance(text);
utterance.rate = parseFloat(document.getElementById('tts-rate').value);
utterance.volume = parseFloat(document.getElementById('tts-volume').value);
utterance.lang = document.documentElement.lang || 'en-US';
utterance.onstart = () => updateStatus('Reading started');
utterance.onend = () => updateStatus('Reading finished');
utterance.onerror = (e) => updateStatus(`Error: ${e.error}`);
synth.speak(utterance);
updateControlStates(true);
}
});
document.getElementById('pause-tts').addEventListener('click', () => {
synth.pause();
isPaused = true;
updateStatus('Reading paused');
});
document.getElementById('stop-tts').addEventListener('click', () => {
synth.cancel();
isPaused = false;
updateStatus('Reading stopped');
updateControlStates(false);
});
function updateStatus(message) {
document.getElementById('tts-status').textContent = message;
}
function updateControlStates(isPlaying) {
document.getElementById('pause-tts').disabled = !isPlaying;
document.getElementById('stop-tts').disabled = !isPlaying;
}
</script>
Speech API Conflicts: Speech synthesis may interfere with screen readers - provide option to
disable. Some users rely on screen reader-specific features. Always provide text alternatives. Respect user's
assistive technology preferences. Test with screen readers active to detect conflicts.
16.2 Geolocation and Privacy
| Privacy Aspect | User Need | Implementation | WCAG Reference |
|---|---|---|---|
| Permission Request | Clear explanation of why location is needed | Explain before requesting; provide context; allow denial without breaking app | WCAG 3.2.2 (On Input), 3.3.2 (Labels or Instructions) |
| Error Handling | Graceful degradation when permission denied | Provide manual location entry; explain errors clearly; offer alternatives | WCAG 3.3.1 (Error Identification), 3.3.3 (Error Suggestion) |
| Status Feedback | Know when location is being accessed | Visual indicator; announce to screen readers; timeout handling | WCAG 4.1.3 (Status Messages) |
| Accuracy Options | Balance privacy and functionality | Allow low-accuracy option; explain tradeoffs; don't require high accuracy unnecessarily | Privacy considerations (WCAG Principle 4) |
| Persistent Storage | Control over saved location data | Explain what's saved; provide delete option; respect DNT header | User control and privacy |
Example: Accessible Geolocation with Clear Permissions
<div class="location-feature">
<h2>Find Nearby Stores</h2>
<div class="note">
<strong>Why we need your location:</strong>
We'll use your approximate location to show stores within 10 miles.
You can also enter a location manually below.
</div>
<button id="use-location">Use My Location</button>
<div class="alternative">
<label for="manual-location">Or enter location manually:</label>
<input type="text"
id="manual-location"
placeholder="City or ZIP code"
aria-describedby="location-help">
<p id="location-help">We'll search near this location</p>
</div>
<div role="status" aria-live="polite"><span id="location-status"></span></div>
<div id="results" aria-live="polite" aria-atomic="true"></div>
</div>
<script>
document.getElementById('use-location').addEventListener('click', async () => {
const status = document.getElementById('location-status');
const results = document.getElementById('results');
if (!navigator.geolocation) {
status.textContent = 'Geolocation not supported. Please enter location manually.';
return;
}
status.textContent = 'Requesting location...';
navigator.geolocation.getCurrentPosition(
// Success
async (position) => {
const { latitude, longitude, accuracy } = position.coords;
status.textContent = `Location found (accurate to ${Math.round(accuracy)} meters)`;
// Fetch nearby stores
const stores = await findNearbyStores(latitude, longitude);
results.innerHTML = `
<h3>Found ${stores.length} stores nearby</h3>
<ul>${stores.map(s => `<li>${s.name} - ${s.distance} miles</li>`).join('')}</ul>
`;
},
// Error
(error) => {
let message = '';
switch(error.code) {
case error.PERMISSION_DENIED:
message = 'Location access denied. Please enter location manually or enable location permissions in your browser settings.';
break;
case error.POSITION_UNAVAILABLE:
message = 'Location information unavailable. Please try again or enter location manually.';
break;
case error.TIMEOUT:
message = 'Location request timed out. Please try again or enter location manually.';
break;
default:
message = 'Unable to get location. Please enter location manually.';
}
status.textContent = message;
},
// Options
{
enableHighAccuracy: false, // Better privacy
timeout: 10000,
maximumAge: 300000 // 5 minutes
}
);
});
</script>
Geolocation Best Practices:
- Always provide manual location input as alternative
- Explain what location will be used for before requesting
- Don't request location on page load - wait for user action
- Handle all error cases with clear, actionable messages
- Consider using lower accuracy (enableHighAccuracy: false) for privacy
- Announce status changes to screen reader users
- Don't break functionality if permission is denied
16.3 File API Accessibility
| File Operation | Accessibility Challenge | Solution | Best Practice |
|---|---|---|---|
| File Input | Drag-and-drop not keyboard accessible | Provide standard file input; label properly; announce file selection | Always include <input type="file"> with visible label |
| Drag and Drop | Mouse-only interaction | Provide keyboard alternative; announce drop zones; visual focus indicators | WCAG 2.1.1 (Keyboard), supplemental to file input |
| File Validation | Error messages need to be announced | Use live regions; associate errors with input; provide clear messages | WCAG 3.3.1 (Error Identification), 3.3.3 (Error Suggestion) |
| Upload Progress | Visual-only progress bars | Use <progress> or role="progressbar" with aria-valuenow; announce milestones | WCAG 4.1.3 (Status Messages) |
| Multiple Files | List management not clear | Announce count; provide remove buttons; keyboard navigation for list | Clear structure and controls |
| File Preview | Images need alt text; documents need alternatives | Extract filename as fallback; provide download option; describe content | WCAG 1.1.1 (Non-text Content) |
Example: Accessible File Upload with Drag-and-Drop
<div class="file-upload">
<label for="file-input">
Choose files to upload
<span class="help-text">(or drag and drop)</span>
</label>
<div class="drop-zone"
role="button"
tabindex="0"
aria-describedby="drop-instructions">
<input type="file"
id="file-input"
multiple
accept="image/*,.pdf"
aria-describedby="file-requirements">
<span class="drop-text">Click to browse or drag files here</span>
</div>
<p id="drop-instructions" class="sr-only">
Activate to select files. Supports multiple file selection.
</p>
<p id="file-requirements">
Accepted formats: Images (JPG, PNG, GIF) and PDF. Maximum 5MB per file.
</p>
<div role="status" aria-live="polite" aria-atomic="true">
<span id="file-status"></span>
</div>
<ul id="file-list" aria-label="Selected files"></ul>
<div id="upload-progress" role="progressbar"
aria-valuenow="0"
aria-valuemin="0"
aria-valuemax="100"
aria-label="Upload progress"
hidden>
<div class="progress-bar"></div>
<span class="progress-text">0%</span>
</div>
</div>
<script>
const fileInput = document.getElementById('file-input');
const dropZone = document.querySelector('.drop-zone');
const fileList = document.getElementById('file-list');
const fileStatus = document.getElementById('file-status');
const selectedFiles = new Map();
// Standard file input
fileInput.addEventListener('change', (e) => {
handleFiles(e.target.files);
});
// Drag and drop handlers
['dragenter', 'dragover'].forEach(eventName => {
dropZone.addEventListener(eventName, (e) => {
e.preventDefault();
dropZone.classList.add('drag-over');
dropZone.setAttribute('aria-dropeffect', 'copy');
});
});
['dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, (e) => {
e.preventDefault();
dropZone.classList.remove('drag-over');
dropZone.removeAttribute('aria-dropeffect');
});
});
dropZone.addEventListener('drop', (e) => {
const files = e.dataTransfer.files;
handleFiles(files);
});
// Keyboard activation for drop zone
dropZone.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
fileInput.click();
}
});
function handleFiles(files) {
let validCount = 0;
let errors = [];
Array.from(files).forEach(file => {
// Validate file
if (file.size > 5 * 1024 * 1024) {
errors.push(`${file.name} is too large (max 5MB)`);
return;
}
if (!file.type.match(/^image\/.*/) && file.type !== 'application/pdf') {
errors.push(`${file.name} is not a supported format`);
return;
}
selectedFiles.set(file.name, file);
validCount++;
});
updateFileList();
// Announce results
let message = '';
if (validCount > 0) {
message = `${validCount} file${validCount !== 1 ? 's' : ''} added. `;
}
if (errors.length > 0) {
message += `${errors.length} file${errors.length !== 1 ? 's' : ''} rejected: ${errors.join(', ')}`;
}
fileStatus.textContent = message;
}
function updateFileList() {
fileList.innerHTML = '';
selectedFiles.forEach((file, name) => {
const li = document.createElement('li');
li.innerHTML = `
${name} (${formatFileSize(file.size)})
<button type="button"
aria-label="Remove ${name}"
data-filename="${name}">Remove</button>
`;
li.querySelector('button').addEventListener('click', (e) => {
selectedFiles.delete(e.target.dataset.filename);
updateFileList();
fileStatus.textContent = `${e.target.dataset.filename} removed`;
});
fileList.appendChild(li);
});
}
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
</script>
Example: Accessible Upload Progress Indicator
<div class="upload-container">
<button id="start-upload">Upload Files</button>
<div class="progress-container" hidden>
<progress id="upload-progress"
max="100"
value="0"
aria-label="Upload progress">
0%
</progress>
<div role="status" aria-live="polite" aria-atomic="true">
<span id="progress-status">Ready to upload</span>
</div>
</div>
</div>
<script>
async function uploadFiles(files) {
const progressBar = document.getElementById('upload-progress');
const progressStatus = document.getElementById('progress-status');
const container = document.querySelector('.progress-container');
container.hidden = false;
const totalSize = Array.from(files).reduce((sum, f) => sum + f.size, 0);
let uploadedSize = 0;
for (const file of files) {
const formData = new FormData();
formData.append('file', file);
try {
await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const fileProgress = (e.loaded / e.total) * 100;
const totalProgress = ((uploadedSize + e.loaded) / totalSize) * 100;
progressBar.value = totalProgress;
// Announce at 25%, 50%, 75%, 100%
const milestone = Math.floor(totalProgress / 25) * 25;
if (milestone > 0 && totalProgress >= milestone && totalProgress < milestone + 2) {
progressStatus.textContent = `${milestone}% complete`;
}
}
});
xhr.addEventListener('load', () => {
uploadedSize += file.size;
resolve();
});
xhr.addEventListener('error', () => {
reject(new Error(`Failed to upload ${file.name}`));
});
xhr.open('POST', '/upload');
xhr.send(formData);
});
} catch (error) {
progressStatus.textContent = error.message;
return;
}
}
progressStatus.textContent = 'Upload complete!';
}
</script>
File API Best Practices:
- Always provide standard file input - drag-and-drop is supplemental
- Validate files client-side and announce errors clearly
- Show file list with remove buttons (keyboard accessible)
- Use native <progress> element or proper ARIA progressbar
- Announce progress milestones (25%, 50%, 75%, 100%)
- Don't announce every percentage change - too verbose
- Provide clear success and error messages
16.4 WebRTC Accessibility Considerations
| WebRTC Feature | Accessibility Challenge | Solution | User Benefit |
|---|---|---|---|
| Video Calls | Deaf/hard of hearing users need captions | Integrate real-time captioning; support sign language interpreters; provide chat alternative | WCAG 1.2.4 (Captions Live) |
| Audio Calls | Blind users need audio-only option; volume control | Allow audio-only mode; provide clear audio controls; support keyboard shortcuts | Reduces bandwidth; supports diverse needs |
| Screen Sharing | Shared content may not be accessible | Warn sharer to check accessibility; provide alt text for shared images; describe visual content | WCAG 1.1.1 (Non-text Content) |
| Connection Status | Visual-only indicators insufficient | Announce connection changes; provide text status; use ARIA live regions | WCAG 4.1.3 (Status Messages) |
| Call Controls | Mute/unmute, camera on/off need clear state | Use aria-pressed; provide keyboard shortcuts; announce state changes | Clear feedback for all users |
| Permissions | Camera/microphone access needs explanation | Explain before requesting; handle denials gracefully; provide status indicators | Privacy and user control |
Example: Accessible Video Call Controls
<div class="video-call" role="group" aria-label="Video call controls">
<div class="video-container">
<video id="remote-video"
aria-label="Remote participant video"
autoplay></video>
<video id="local-video"
aria-label="Your video preview"
autoplay
muted></video>
</div>
<div class="controls" role="toolbar" aria-label="Call controls">
<button id="toggle-mic"
aria-pressed="true"
aria-label="Microphone on"
title="Mute (Ctrl+D)">
<span aria-hidden="true">🎤</span>
</button>
<button id="toggle-camera"
aria-pressed="true"
aria-label="Camera on"
title="Turn off camera (Ctrl+E)">
<span aria-hidden="true">📹</span>
</button>
<button id="toggle-screen"
aria-pressed="false"
aria-label="Screen sharing off"
title="Share screen (Ctrl+Shift+E)">
<span aria-hidden="true">🖥️</span>
</button>
<button id="end-call"
aria-label="End call"
class="danger">
End Call
</button>
</div>
<div class="status-bar">
<div role="status" aria-live="polite" aria-atomic="true">
<span id="connection-status">Connected</span>
</div>
<div role="timer" aria-live="off" aria-atomic="true">
<span id="call-duration">00:00</span>
</div>
</div>
<div class="chat-alternative">
<button id="open-chat" aria-label="Open text chat">💬 Chat</button>
</div>
</div>
<script>
let localStream = null;
let isMicOn = true;
let isCameraOn = true;
const status = document.getElementById('connection-status');
// Toggle microphone
document.getElementById('toggle-mic').addEventListener('click', function() {
isMicOn = !isMicOn;
if (localStream) {
localStream.getAudioTracks()[0].enabled = isMicOn;
}
this.setAttribute('aria-pressed', isMicOn);
this.setAttribute('aria-label', isMicOn ? 'Microphone on' : 'Microphone off');
status.textContent = isMicOn ? 'Microphone unmuted' : 'Microphone muted';
});
// Toggle camera
document.getElementById('toggle-camera').addEventListener('click', function() {
isCameraOn = !isCameraOn;
if (localStream) {
localStream.getVideoTracks()[0].enabled = isCameraOn;
}
this.setAttribute('aria-pressed', isCameraOn);
this.setAttribute('aria-label', isCameraOn ? 'Camera on' : 'Camera off');
status.textContent = isCameraOn ? 'Camera turned on' : 'Camera turned off';
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'd') {
e.preventDefault();
document.getElementById('toggle-mic').click();
} else if (e.ctrlKey && e.key === 'e') {
e.preventDefault();
document.getElementById('toggle-camera').click();
}
});
// Initialize WebRTC
async function startCall() {
try {
status.textContent = 'Requesting camera and microphone access...';
localStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
document.getElementById('local-video').srcObject = localStream;
status.textContent = 'Connected';
} catch (error) {
let message = 'Unable to access camera/microphone. ';
if (error.name === 'NotAllowedError') {
message += 'Please grant permission in your browser settings.';
} else if (error.name === 'NotFoundError') {
message += 'No camera or microphone found.';
} else {
message += 'Please check your device settings.';
}
status.textContent = message;
}
}
</script>
WebRTC Accessibility Gaps: Real-time captioning requires third-party services or browser
features. Sign language interpretation needs high-quality video. Network issues affect quality - provide status
updates. Always provide text chat as alternative. Test with assistive technologies. Consider bandwidth
requirements for users on limited connections.
16.5 Service Worker and Offline Access
| Offline Feature | Accessibility Requirement | Implementation | User Experience |
|---|---|---|---|
| Offline Detection | Announce online/offline status changes | Use online/offline events; update UI; announce via live region | WCAG 4.1.3 (Status Messages) |
| Cached Content | Indicate when content is stale or offline | Show cache timestamp; provide refresh action; explain limitations | User understanding of data freshness |
| Sync Status | Progress and completion feedback | Use progressbar or status; announce sync completion; handle errors | Clear feedback on data synchronization |
| Offline Forms | Save drafts locally; indicate pending submission | Auto-save to IndexedDB; show pending badge; sync when online | Prevents data loss |
| Error Recovery | Clear explanation when offline features fail | Provide actionable error messages; retry mechanisms; manual sync option | WCAG 3.3.3 (Error Suggestion) |
| Storage Limits | Warn before quota exceeded | Monitor storage; provide cleanup options; explain consequences | Prevent unexpected failures |
Example: Accessible Offline Status Notification
<div class="app-header">
<div class="connection-indicator"
role="status"
aria-live="polite"
aria-atomic="true">
<span id="connection-badge" class="badge badge-online">Online</span>
</div>
</div>
<div id="offline-banner"
role="alert"
class="banner warning"
hidden>
<strong>You're offline.</strong>
Changes will be saved locally and synced when you reconnect.
<button id="dismiss-banner" aria-label="Dismiss offline notice">×</button>
</div>
<script>
const badge = document.getElementById('connection-badge');
const banner = document.getElementById('offline-banner');
function updateConnectionStatus(isOnline) {
if (isOnline) {
badge.textContent = 'Online';
badge.className = 'badge badge-online';
banner.hidden = true;
// Trigger background sync if available
if ('serviceWorker' in navigator && 'sync' in window.ServiceWorkerRegistration.prototype) {
navigator.serviceWorker.ready.then(reg => {
return reg.sync.register('sync-data');
});
}
} else {
badge.textContent = 'Offline';
badge.className = 'badge badge-offline';
banner.hidden = false;
}
}
// Listen for online/offline events
window.addEventListener('online', () => updateConnectionStatus(true));
window.addEventListener('offline', () => updateConnectionStatus(false));
// Initial status
updateConnectionStatus(navigator.onLine);
// Dismiss banner
document.getElementById('dismiss-banner').addEventListener('click', () => {
banner.hidden = true;
});
</script>
Example: Accessible Background Sync with Progress
<div class="sync-status">
<div role="status" aria-live="polite" aria-atomic="true">
<span id="sync-message">All changes saved</span>
</div>
<button id="manual-sync" aria-describedby="sync-description">
Sync Now
</button>
<p id="sync-description" class="help-text">
Manually sync pending changes to server
</p>
<div id="sync-progress" hidden>
<progress id="sync-bar" max="100" value="0"></progress>
<span id="sync-percent">0%</span>
</div>
</div>
<script>
// Service Worker registration
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').then(registration => {
console.log('Service Worker registered');
// Listen for sync events from SW
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data.type === 'SYNC_PROGRESS') {
updateSyncProgress(event.data.progress);
} else if (event.data.type === 'SYNC_COMPLETE') {
updateSyncMessage('All changes synced successfully');
} else if (event.data.type === 'SYNC_ERROR') {
updateSyncMessage(`Sync failed: ${event.data.error}`);
}
});
});
}
// Manual sync trigger
document.getElementById('manual-sync').addEventListener('click', async () => {
if (!navigator.onLine) {
updateSyncMessage('Cannot sync while offline');
return;
}
const button = document.getElementById('manual-sync');
button.disabled = true;
updateSyncMessage('Syncing changes...');
try {
await syncPendingChanges();
updateSyncMessage('Sync completed successfully');
} catch (error) {
updateSyncMessage(`Sync failed: ${error.message}. Please try again.`);
} finally {
button.disabled = false;
}
});
function updateSyncMessage(message) {
document.getElementById('sync-message').textContent = message;
}
function updateSyncProgress(percent) {
const progressDiv = document.getElementById('sync-progress');
const progressBar = document.getElementById('sync-bar');
const progressText = document.getElementById('sync-percent');
progressDiv.hidden = percent === 100;
progressBar.value = percent;
progressText.textContent = `${percent}%`;
// Announce milestones
if (percent === 25 || percent === 50 || percent === 75 || percent === 100) {
updateSyncMessage(`Sync ${percent}% complete`);
}
}
async function syncPendingChanges() {
// Get pending changes from IndexedDB
const db = await openDatabase();
const pending = await getPendingChanges(db);
const total = pending.length;
let synced = 0;
for (const change of pending) {
await fetch('/api/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(change)
});
synced++;
updateSyncProgress(Math.round((synced / total) * 100));
}
}
</script>
Service Worker Accessibility Best Practices:
- Always announce online/offline status changes
- Indicate when viewing cached/stale content
- Provide manual sync option for user control
- Show clear progress for background operations
- Handle sync failures gracefully with actionable messages
- Persist form data locally to prevent loss during offline periods
- Don't silently fail - inform users of sync status
- Consider users on limited/metered connections
Browser APIs and Modern Features Summary
- Web Speech API: Provide keyboard/mouse alternatives; announce recognition status; control TTS to avoid screen reader conflicts
- Geolocation: Explain before requesting; provide manual input alternative; handle all error cases gracefully
- File API: Always include standard file input; validate and announce errors; show progress with milestones; keyboard-accessible file list
- WebRTC: Support captions for video; provide audio-only mode; announce connection status; keyboard shortcuts for controls; text chat alternative
- Service Workers: Announce online/offline status; indicate cached content; show sync progress; handle offline forms; manual sync option
- Common Patterns: Request permissions with clear explanation; graceful degradation; status announcements; keyboard accessibility; error handling
- Privacy: Explain data usage; respect user preferences; provide alternatives; handle denials gracefully
- Testing: Test offline scenarios; verify announcements; keyboard-only operation; cross-browser compatibility; assistive technology testing
17. Debugging and Troubleshooting
17.1 Browser DevTools for Accessibility
| Browser | DevTools Feature | Access Method | Key Capabilities |
|---|---|---|---|
| Chrome/Edge | Accessibility Panel | DevTools → Elements → Accessibility tab | Accessibility tree, ARIA properties, computed properties, contrast checker |
| Chrome | Lighthouse | DevTools → Lighthouse → Accessibility category | Automated audit with actionable suggestions, performance scoring |
| Chrome | CSS Overview | DevTools → More tools → CSS Overview | Contrast issues detection across entire page |
| Chrome | Rendering Panel | DevTools → More tools → Rendering | Emulate vision deficiencies, prefers-reduced-motion, prefers-color-scheme, forced colors |
| Firefox | Accessibility Inspector | DevTools → Accessibility | Full accessibility tree, relationship visualization, simulation tools, audit |
| Firefox | Accessibility Picker | Accessibility Inspector → Pick element | Click to inspect element's a11y properties, keyboard navigation path |
| Safari | Accessibility Audit | Develop → Show Web Inspector → Audit tab | Built-in accessibility audit with detailed issues |
| Safari | Element Details | Inspector → Node → Accessibility | Computed role, label, value, accessibility tree |
Example: Using Chrome DevTools Accessibility Panel
// Steps to debug accessibility in Chrome:
1. Open DevTools (F12 or Cmd+Option+I)
2. Select Elements tab
3. Choose an element in the DOM tree
4. Click "Accessibility" tab in the right panel
// What you can inspect:
- Computed Properties: role, name, description
- ARIA Attributes: all aria-* values
- Accessibility Tree: hierarchical view
- Source order vs. visual order
- Contrast ratio for text elements
// Using Lighthouse for automated testing:
1. DevTools → Lighthouse tab
2. Select "Accessibility" category
3. Choose device (Mobile/Desktop)
4. Click "Generate report"
// Results include:
- Score (0-100)
- Passed audits
- Failed audits with elements affected
- Manual checks to perform
- Detailed documentation links
Example: Firefox Accessibility Inspector Features
// Advanced features in Firefox DevTools:
1. Accessibility Panel:
- Full accessibility tree view
- Shows all ARIA roles and properties
- Relationship arrows (labelledby, describedby, controls)
- Tabbing order visualization
2. Simulation Tools:
- Simulate: Click "Simulate" dropdown
- Options: None, Protanopia, Deuteranopia, Tritanopia,
Achromatopsia, Contrast loss
3. Keyboard Navigation Checker:
- Shows tab order with numbers
- Highlights focusable elements
- Identifies keyboard traps
4. Check for Issues:
- Click "Check for issues" button
- Filters: All issues, Contrast, Keyboard, Text labels
- Shows count and severity
DevTools Tips:
- Chrome: Use Rendering panel to test prefers-reduced-motion, prefers-color-scheme, forced-colors-mode
- Firefox: Best accessibility tree visualization with relationship arrows
- Safari: Most accurate for testing on macOS with VoiceOver integration
- Contrast Checker: All browsers show contrast ratio - aim for 4.5:1 (text) or 3:1 (large text)
- Accessibility Tree: Shows what screen readers actually see (different from DOM tree)
- Emulation: Test color blindness, reduced motion, dark mode without changing system settings
17.2 Screen Reader Debug Techniques
| Screen Reader | Debug Technique | Keyboard Shortcut | What to Check |
|---|---|---|---|
| NVDA (Windows) | Speech Viewer | NVDA Menu → Tools → Speech Viewer | Visual display of everything NVDA announces - verify announcements without audio |
| NVDA | Element List | NVDA+F7 | Lists landmarks, headings, links, form fields - verify structure |
| NVDA | Browse/Focus Mode | NVDA+Space (toggle) | Browse mode for reading, Focus mode for forms - verify mode switching |
| JAWS (Windows) | Virtual Viewer | Insert+Spacebar, V | Shows virtual buffer content as JAWS sees it |
| JAWS | Forms Mode | Enter/Esc (auto switches) | Verify forms mode activates correctly for interactive elements |
| JAWS | Quick Navigation | H (headings), K (links), F (form fields) | Navigate by element type - verify all elements are reachable |
| VoiceOver (macOS) | Rotor | VO+U | Lists landmarks, headings, links, form controls - verify organization |
| VoiceOver | Item Chooser | VO+I | Search for specific text or elements - verify labeling |
| VoiceOver | Web Rotor | VO+U (on web content) | Navigate by landmarks, headings, links, tables, form controls |
| TalkBack (Android) | Reading Controls | Swipe up/down with one finger | Change navigation granularity - verify content structure |
| Narrator (Windows) | Scan Mode | Narrator+Space (toggle) | Browse vs interact mode - verify mode transitions |
Example: NVDA Testing Workflow
// Essential NVDA shortcuts for debugging:
NVDA+N // Open NVDA menu
NVDA+Q // Quit NVDA
NVDA+T // Read title
NVDA+B // Read status bar
// Navigation:
H / Shift+H // Next/Previous heading
K / Shift+K // Next/Previous link
F / Shift+F // Next/Previous form field
B / Shift+B // Next/Previous button
D / Shift+D // Next/Previous landmark
1-6 / Shift+1-6 // Next/Previous heading level
// Reading:
Insert+↓ // Say all (read from current position)
NVDA+F7 // Element list (landmarks, headings, links, etc.)
NVDA+Space // Toggle browse/focus mode
// Debugging specific elements:
NVDA+Tab // Report current element (role, name, state)
Insert+F // Report formatting
NVDA+F // Find text on page
// Testing live regions:
NVDA+Shift+R // Toggle between live region verbosity modes
NVDA+5 (numpad) // Report current location
// Common issues to check:
1. Speech Viewer shows actual announcements
2. Browse mode works for static content
3. Focus mode activates for form controls
4. All interactive elements announced correctly
5. ARIA live regions announce changes
6. No unexpected announcement duplication
Example: VoiceOver Testing Workflow
// Essential VoiceOver shortcuts for debugging:
// (VO = Control+Option)
VO+F5 // Start VoiceOver
VO+F8 // Open VoiceOver Utility
Cmd+F5 // Toggle VoiceOver on/off
// Navigation:
VO+→ // Move to next item
VO+← // Move to previous item
VO+Shift+↓ // Enter group/container
VO+Shift+↑ // Exit group/container
// Web Rotor (navigate by element type):
VO+U // Open rotor
← / → // Switch category (headings, links, landmarks, etc.)
↓ / ↑ // Select item in category
Enter // Navigate to selected item
// Reading:
VO+A // Read from current position
VO+Shift+U // Open Item Chooser (search)
// Debugging:
VO+F3 // Read caption (accessibility label)
VO+F4 // Read hint
VO+Shift+H // Hear element description
VO+J // Jump to linked item
// Verbosity settings:
VO+V // Open Verbosity menu
// Adjust announcement detail level
// Testing checklist:
1. All elements reachable with VO+→
2. Rotor shows proper structure (headings, landmarks)
3. Forms mode activates for inputs
4. Buttons announce role and state
5. Live regions announce updates
6. Table headers announced with cells
7. Image alt text read correctly
Screen Reader Testing Gotchas: Different screen readers interpret ARIA differently. NVDA
announces aria-label, JAWS prefers visible text. Live regions work inconsistently - test all SRs. Browse/Focus
mode transitions vary. Virtual buffer can cache old content - refresh with F5. Screen readers may announce in
unexpected order - use DevTools accessibility tree to verify. Always test with actual users when possible.
17.3 Common Accessibility Issues and Fixes
| Common Issue | Symptom | Quick Fix | WCAG Violation |
|---|---|---|---|
| Missing Alt Text | Images announced as filename or "image" | Add alt="" for decorative, descriptive text for meaningful images |
1.1.1 Non-text Content |
| Poor Color Contrast | Text hard to read against background | Use contrast checker tool; aim for 4.5:1 (normal text) or 3:1 (large text/UI) | 1.4.3 Contrast (Minimum) |
| Unlabeled Form Inputs | Screen reader announces "Edit, blank" without context | Add <label for="id"> or aria-label; ensure visible label exists | 1.3.1 Info and Relationships, 3.3.2 Labels |
| Keyboard Trap | Cannot Tab out of modal/widget | Implement focus trap with Esc key exit; manage focus on close | 2.1.2 No Keyboard Trap |
| Missing Focus Indicators | Can't see which element is focused | Never use outline: none without custom replacement; ensure 3:1 contrast |
2.4.7 Focus Visible |
| Incorrect Heading Order | Skip from H1 to H3; multiple H1s; headings out of order | Use sequential heading levels (H1 → H2 → H3); single H1 per page | 1.3.1 Info and Relationships, 2.4.6 Headings |
| Button with Div/Span | <div onclick> not keyboard accessible or announced as button | Use <button> element; if unavoidable, add role="button" and tabindex="0" | 4.1.2 Name, Role, Value |
| Empty Links/Buttons | Announced as "Link" or "Button" without purpose | Add visible text, aria-label, or sr-only text; never leave empty | 2.4.4 Link Purpose, 4.1.2 Name, Role, Value |
| Auto-playing Media | Video/audio starts automatically, interferes with screen reader | Remove autoplay; provide play/pause controls; respect prefers-reduced-motion | 1.4.2 Audio Control, 2.2.2 Pause, Stop, Hide |
| Tables for Layout | Data announced as table when it's just visual layout | Use CSS Grid/Flexbox for layout; reserve <table> for tabular data only | 1.3.1 Info and Relationships |
| Missing Language Attribute | Screen reader uses wrong pronunciation/voice | Add lang="en" to <html>; use lang on elements with different languages |
3.1.1 Language of Page, 3.1.2 Language of Parts |
| Non-descriptive Link Text | "Click here" or "Read more" without context | Use descriptive link text; add aria-label with context; avoid generic phrases | 2.4.4 Link Purpose |
Example: Quick Fixes Cheat Sheet
// Missing alt text - FIX:
<!-- Before: -->
<img src="logo.png">
<!-- After: -->
<img src="logo.png" alt="Company Name Logo">
<!-- Decorative: -->
<img src="decorative.png" alt="">
// Poor contrast - FIX:
/* Before: */
.text { color: #777; background: #fff; } /* 3.3:1 - FAIL */
/* After: */
.text { color: #595959; background: #fff; } /* 4.5:1 - PASS */
// Unlabeled input - FIX:
<!-- Before: -->
<input type="text" placeholder="Enter name">
<!-- After: -->
<label for="name">Name:</label>
<input type="text" id="name" placeholder="e.g., John Smith">
// Non-semantic button - FIX:
<!-- Before: -->
<div onclick="submit()">Submit</div>
<!-- After: -->
<button type="button" onclick="submit()">Submit</button>
// Missing focus indicator - FIX:
/* Before: */
button:focus { outline: none; } /* BAD */
/* After: */
button:focus-visible {
outline: 2px solid #005fcc;
outline-offset: 2px;
}
// Incorrect heading order - FIX:
<!-- Before: -->
<h1>Page Title</h1>
<h3>Section</h3> <!-- Skipped H2 -->
<!-- After: -->
<h1>Page Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>
// Empty link - FIX:
<!-- Before: -->
<a href="/profile"><img src="user.png"></a>
<!-- After: -->
<a href="/profile">
<img src="user.png" alt="View profile">
</a>
<!-- Or: -->
<a href="/profile" aria-label="View profile">
<img src="user.png" aria-hidden="true">
</a>
// Non-descriptive link - FIX:
<!-- Before: -->
<a href="/article1">Read more</a>
<!-- After: -->
<a href="/article1">Read more about Web Accessibility</a>
<!-- Or: -->
<a href="/article1" aria-label="Read more about Web Accessibility">
Read more
</a>
Debugging Workflow:
- Automated scan: Run Lighthouse, axe DevTools, or WAVE to catch obvious issues
- Keyboard test: Navigate entire page with Tab/Shift+Tab, Enter, Spacebar, arrows - no mouse
- Screen reader test: Test with NVDA (Windows), VoiceOver (Mac), or TalkBack (Android)
- Visual inspection: Check contrast, focus indicators, zoom to 200%, test dark mode
- Manual testing: Follow WCAG checklist for manual checks (color not sole indicator, etc.)
- User testing: Get feedback from actual users with disabilities when possible
17.4 ARIA Implementation Debugging
| ARIA Issue | How to Detect | Common Cause | Solution |
|---|---|---|---|
| ARIA not announced | Screen reader ignores aria-label or aria-describedby | Used on non-labelable element (div, span without role) | Add appropriate role or use semantic HTML; verify in accessibility tree |
| Conflicting roles | Element announced incorrectly or multiple times | Native role conflicts with ARIA role (e.g., role="button" on <button>) | Remove redundant ARIA; prefer semantic HTML over ARIA |
| Invalid ARIA reference | aria-labelledby or aria-describedby silent | Referenced ID doesn't exist or typo in ID | Verify IDs exist and match exactly (case-sensitive); check in DevTools |
| Missing required ARIA | Widget doesn't function properly with screen reader | Role requires specific attributes (e.g., tablist needs aria-selected) | Check ARIA APG for required attributes; validate with axe or WAVE |
| Incorrect aria-live | Updates not announced or announced too much | Wrong politeness level or aria-atomic setting | Use polite for most cases; assertive sparingly; test with SR |
| Dynamic ARIA not updating | State changes not reflected to screen reader | ARIA attributes not updated when state changes | Update aria-expanded, aria-selected, etc. in JavaScript; verify in DevTools |
| aria-hidden issues | Important content not announced or decorative content announced | aria-hidden="true" on focusable elements or critical content | Never hide focusable elements; ensure visible text isn't hidden |
| Redundant ARIA | Duplicate announcements or verbose output | Both aria-label and visible label present | Choose one labeling method; use aria-labelledby for visible label |
Example: Debugging ARIA with DevTools
// Check accessibility tree in Chrome DevTools:
// 1. Select element in Elements panel
// 2. Open Accessibility tab
// 3. Look for:
Computed Properties:
Name: "Submit form" // From aria-label, label, or content
Role: "button" // Semantic or ARIA role
Description: "Save changes" // From aria-describedby
// Common issues to look for:
// Issue: Name is empty
<button></button>
// Fix: Add text content or aria-label
<button>Submit</button>
<button aria-label="Submit form"><span aria-hidden="true">✓</span></button>
// Issue: Role is "generic" when it should be specific
<div onclick="doSomething()">Click me</div>
// Fix: Add role and make keyboard accessible
<div role="button" tabindex="0" onclick="doSomething()">Click me</div>
// Better: Use semantic HTML
<button onclick="doSomething()">Click me</button>
// Issue: aria-labelledby references non-existent ID
<button aria-labelledby="submit-label">Send</button>
// No element with id="submit-label" exists
// Fix: Create the element or remove the attribute
<span id="submit-label">Submit form</span>
<button aria-labelledby="submit-label">Send</button>
// Issue: Required ARIA attributes missing
<div role="tab">Tab 1</div>
// Missing: aria-selected, aria-controls
// Fix: Add required attributes
<div role="tab"
aria-selected="true"
aria-controls="panel1"
tabindex="0">Tab 1</div>
// Validate ARIA with axe DevTools:
const axe = require('axe-core');
axe.run(document, {
rules: {
'aria-allowed-attr': { enabled: true },
'aria-required-attr': { enabled: true },
'aria-required-children': { enabled: true },
'aria-required-parent': { enabled: true },
'aria-valid-attr': { enabled: true },
'aria-valid-attr-value': { enabled: true }
}
}, (err, results) => {
console.log(results.violations);
});
Example: Common ARIA Mistakes and Fixes
// MISTAKE 1: aria-label on div without role
<div aria-label="Important message">Content</div>
// FIX: Add appropriate role or use semantic element
<div role="region" aria-label="Important message">Content</div>
<section aria-label="Important message">Content</section>
// MISTAKE 2: Hiding focusable element
<button aria-hidden="true">Click me</button>
// FIX: Remove aria-hidden or make non-focusable
<button>Click me</button>
<span aria-hidden="true">Decorative icon</span>
// MISTAKE 3: Using role on semantic element unnecessarily
<button role="button">Submit</button>
<nav role="navigation">...</nav>
// FIX: Remove redundant role
<button>Submit</button>
<nav>...</nav>
// MISTAKE 4: Invalid ARIA attribute value
<button aria-pressed="yes">Toggle</button>
// FIX: Use valid value (true/false/mixed)
<button aria-pressed="true">Toggle</button>
// MISTAKE 5: Missing required children
<div role="list">
<div>Item 1</div>
<div>Item 2</div>
</div>
// FIX: Add required child role
<div role="list">
<div role="listitem">Item 1</div>
<div role="listitem">Item 2</div>
</div>
// Or use semantic HTML
<ul>
<li>Item 1</li>
<li>Item 2</li>
</ul>
// MISTAKE 6: Live region not in DOM on load
// Wrong: Creating live region dynamically
function announce(message) {
const live = document.createElement('div');
live.setAttribute('role', 'status');
live.textContent = message;
document.body.appendChild(live);
}
// FIX: Create live region once on page load
<div role="status" aria-live="polite" class="sr-only"></div>
function announce(message) {
document.querySelector('[role="status"]').textContent = message;
}
// MISTAKE 7: Not updating dynamic ARIA
<button aria-expanded="false" onclick="toggle()">Menu</button>
function toggle() {
menu.hidden = !menu.hidden;
// Missing: Update aria-expanded
}
// FIX: Update ARIA attribute
function toggle() {
const isExpanded = menu.hidden;
menu.hidden = !menu.hidden;
button.setAttribute('aria-expanded', isExpanded);
}
ARIA First Rule: "No ARIA is better than bad ARIA." Use semantic HTML first. Add ARIA only when
HTML can't express the semantics. Test extensively with screen readers - behavior varies. Validate with
automated tools (axe, WAVE, Lighthouse). Check accessibility tree in DevTools. Read ARIA APG for correct
patterns.
17.5 Cross-Browser Compatibility Testing
| Test Scenario | Browsers to Test | Known Issues | Testing Strategy |
|---|---|---|---|
| Screen Reader + Browser | JAWS+Chrome, NVDA+Firefox, VoiceOver+Safari, Narrator+Edge, TalkBack+Chrome | ARIA support varies; live regions inconsistent; focus management differs | Test primary SR+browser combo for your region; document known issues |
| Keyboard Navigation | Chrome, Firefox, Safari, Edge | Safari Tab behavior different (requires enabling); Focus order can vary | Test Tab, Shift+Tab, Enter, Space, arrows in all browsers |
| Focus Indicators | All major browsers | Default indicators differ; :focus-visible support varies (older browsers) | Test with Tab navigation; verify 3:1 contrast in all browsers |
| Form Validation | Chrome, Firefox, Safari, Edge | HTML5 validation messages vary; required attribute styling differs | Test with SR; verify error announcement; check visual indicators |
| ARIA Live Regions | Test with each SR+browser combo | Politeness levels interpreted differently; atomic behavior varies | Test dynamic content updates; verify announcements in each SR |
| CSS Media Queries | Chrome, Firefox, Safari (desktop & mobile) | prefers-reduced-motion not supported in IE11; prefers-contrast limited support | Test with system settings; verify fallbacks for unsupported queries |
| Touch Targets (Mobile) | Chrome Android, Safari iOS, Samsung Internet | Touch target calculation differs; zoom behavior varies | Test on actual devices; verify 44×44px minimum; check spacing |
| Zoom and Reflow | All major browsers at 200%, 400% | Text may not scale properly; horizontal scroll may appear | Zoom to 200% and 400%; verify no horizontal scroll; content readable |
Example: Browser Compatibility Testing Checklist
// Essential Browser + Screen Reader Combinations:
Windows:
- NVDA + Firefox (most common free combination)
- JAWS + Chrome (enterprise standard)
- Narrator + Edge (built-in Windows)
macOS:
- VoiceOver + Safari (most accurate pairing)
- VoiceOver + Chrome (secondary testing)
iOS:
- VoiceOver + Safari (mobile)
Android:
- TalkBack + Chrome (mobile)
// Testing workflow:
1. Automated testing (same across browsers):
- Run Lighthouse in Chrome
- Run axe DevTools extension
- Validate HTML with W3C Validator
2. Keyboard testing (each browser):
✓ Tab through entire page
✓ Shift+Tab reverses correctly
✓ Enter activates links/buttons
✓ Space activates buttons/toggles
✓ Arrow keys work in custom widgets
✓ Esc closes modals/menus
✓ No keyboard traps
3. Screen reader testing (each combination):
✓ Page structure (headings, landmarks)
✓ All content reachable
✓ Forms properly labeled
✓ Error messages announced
✓ Dynamic content updates announced
✓ Images have appropriate alt text
✓ Tables announce headers
4. Visual testing (each browser):
✓ Focus indicators visible (3:1 contrast)
✓ Text contrast meets WCAG AA
✓ Zoom to 200% - no horizontal scroll
✓ Dark mode works correctly
✓ High contrast mode (Windows)
5. Mobile testing (iOS + Android):
✓ Touch targets 44×44px minimum
✓ Pinch zoom enabled
✓ Screen reader gestures work
✓ Content reflows for small screens
✓ No reliance on hover
Example: Known Browser Differences to Test
// Safari specific:
// Enable Tab navigation: Safari Preferences → Advanced →
// "Press Tab to highlight each item on a webpage"
// Test in Safari specifically:
- Tab behavior different from other browsers
- Form validation messages styled differently
- VoiceOver integration most accurate with Safari
- Some ARIA features work only in Safari+VO
// Firefox specific:
// Best accessibility DevTools
- Most standards-compliant ARIA implementation
- Preferred for NVDA testing
- Focus management most predictable
// Chrome/Edge specific:
// Chromium-based - similar behavior
- Best Lighthouse integration
- Most common browser globally
- Test with JAWS (common enterprise SR)
// Mobile Safari (iOS):
// VoiceOver built-in
- Swipe right/left to navigate
- Two-finger scroll
- Rotor gestures (VO+U equivalent)
- Test zoom and reflow
// Chrome Android:
// TalkBack built-in on most Android
- Swipe right/left to navigate
- Explore by touch
- Volume key shortcuts
- Test with different screen sizes
// Feature support differences:
// Always check caniuse.com for:
const features = {
'focus-visible': 'Safari partial support',
'prefers-reduced-motion': 'IE11 not supported',
'prefers-color-scheme': 'IE11 not supported',
'prefers-contrast': 'Limited support',
'inert': 'Chrome 102+, Safari 15.5+',
'aria-description': 'Chrome 83+, limited SR support'
};
// Provide fallbacks:
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; }
}
/* Fallback for older browsers */
.no-prefers-reduced-motion {
animation: none !important;
}
Cross-Browser Testing Best Practices:
- Prioritize: Test with most common SR+browser combo in your region (NVDA+Firefox in US/EU, JAWS+Chrome in enterprise)
- Document: Keep track of known issues and workarounds for each browser
- Automate: Use CI/CD with automated a11y tests (pa11y, axe-core, Lighthouse CI)
- Real Devices: Test mobile on actual devices, not just emulators
- Progressive Enhancement: Build for baseline, enhance for modern browsers
- Polyfills: Use for critical features (focus-visible, inert) in older browsers
- User Testing: Get feedback from real users with disabilities across different browsers/SRs
- Monitor: Track browser/SR usage in your analytics to prioritize testing
Debugging and Troubleshooting Summary
- DevTools: Chrome Lighthouse & Accessibility panel; Firefox best a11y inspector; Safari for VoiceOver testing
- Screen Readers: NVDA Speech Viewer for visual testing; VoiceOver Rotor for structure; test with actual SRs, not just DevTools
- Common Issues: Missing alt text, poor contrast, unlabeled inputs, keyboard traps, missing focus indicators - use automated tools first
- ARIA Debugging: Check accessibility tree in DevTools; validate with axe; never hide focusable elements; update dynamic ARIA
- Cross-Browser: Test NVDA+Firefox, JAWS+Chrome, VoiceOver+Safari; verify keyboard, SR, zoom, mobile; document known issues
- Testing Workflow: Automated scan → Keyboard test → Screen reader → Visual inspection → User testing
- Tools: Lighthouse, axe DevTools, WAVE, NVDA/JAWS/VoiceOver, browser DevTools, contrast checkers
- Best Practices: Test early and often; fix issues as you build; use semantic HTML first; validate with real users
18. Legal Compliance Quick Reference
18.1 WCAG 2.2 AA Success Criteria
| Criterion | Level | Requirement | Quick Check |
|---|---|---|---|
| 1.1.1 Non-text Content | A | All images, icons, and non-text content have text alternatives | Every <img> has alt attribute; decorative images have alt="" |
| 1.2.1 Audio-only/Video-only | A | Prerecorded audio has transcript; prerecorded video has audio description or transcript | Provide transcript link for audio; description or transcript for video |
| 1.2.2 Captions (Prerecorded) | A | Captions provided for all prerecorded audio in video | Video has <track kind="captions"> or embedded captions |
| 1.2.4 Captions (Live) | AA | Captions provided for all live audio in video | Live video includes real-time captions |
| 1.2.5 Audio Description | AA | Audio description for all prerecorded video | Video has audio track describing visual information |
| 1.3.1 Info and Relationships | A | Structure can be programmatically determined | Use semantic HTML; proper heading hierarchy; table headers; form labels |
| 1.3.2 Meaningful Sequence | A | Correct reading order can be programmatically determined | DOM order matches visual order; test with screen reader |
| 1.3.4 Orientation | AA | Content not restricted to single orientation unless essential | Works in both portrait and landscape modes |
| 1.3.5 Identify Input Purpose | AA | Input purpose can be programmatically determined | Use autocomplete attributes for common fields (name, email, etc.) |
| 1.4.3 Contrast (Minimum) | AA | 4.5:1 for normal text; 3:1 for large text (18pt+ or 14pt+ bold) | Use contrast checker tool; test all text against backgrounds |
| 1.4.4 Resize Text | AA | Text can be resized up to 200% without loss of content or functionality | Zoom to 200%; verify no horizontal scroll; all content readable |
| 1.4.10 Reflow | AA | Content reflows to 320px width without horizontal scrolling | Resize browser to 320px; no horizontal scroll except tables/diagrams |
| 1.4.11 Non-text Contrast | AA | 3:1 contrast for UI components and graphical objects | Buttons, inputs, focus indicators have 3:1 contrast |
| 1.4.12 Text Spacing | AA | No loss of content with increased spacing (1.5 line height, 2x paragraph spacing, etc.) | Apply text spacing CSS; verify all content visible |
| 1.4.13 Content on Hover/Focus | AA | Additional content from hover/focus is dismissible, hoverable, and persistent | Tooltips can be dismissed (Esc), hovered, and don't disappear unexpectedly |
| 2.1.1 Keyboard | A | All functionality available via keyboard | Navigate entire page with Tab, Enter, Space, arrows only |
| 2.1.2 No Keyboard Trap | A | Keyboard focus can move away from any component | Can Tab out of all widgets; Esc closes modals |
| 2.1.4 Character Key Shortcuts NEW | A | Single-key shortcuts can be turned off, remapped, or only active on focus | Single-letter shortcuts don't interfere with typing; provide disable option |
| 2.2.1 Timing Adjustable | A | User can turn off, adjust, or extend time limits | Provide settings to disable timeouts or extend them |
| 2.2.2 Pause, Stop, Hide | A | Moving, blinking, or auto-updating content can be paused, stopped, or hidden | Carousels have pause button; videos don't autoplay |
| 2.4.1 Bypass Blocks | A | Mechanism to skip repeated content | Skip to main content link; proper landmark usage |
| 2.4.2 Page Titled | A | Pages have descriptive and informative titles | <title> uniquely describes page purpose |
| 2.4.3 Focus Order | A | Focusable elements receive focus in meaningful sequence | Tab order follows visual/logical order |
| 2.4.4 Link Purpose | A | Purpose of each link determined from link text or context | No "click here" links; descriptive link text |
| 2.4.5 Multiple Ways | AA | More than one way to find pages (sitemap, search, navigation) | Provide navigation menu, search, and/or sitemap |
| 2.4.6 Headings and Labels | AA | Headings and labels describe topic or purpose | Clear, descriptive headings; form labels explain purpose |
| 2.4.7 Focus Visible | AA | Keyboard focus indicator is visible | Clear focus outline on all interactive elements |
| 2.4.11 Focus Not Obscured (Minimum) NEW 2.2 | AA | Focused element is at least partially visible | Sticky headers don't completely hide focused elements |
| 2.5.1 Pointer Gestures | A | Multi-point or path-based gestures have single-pointer alternative | Pinch zoom has +/- buttons; swipe has arrow buttons |
| 2.5.2 Pointer Cancellation | A | Single-pointer activation can be aborted or undone | Click activates on up event, not down; can cancel by moving away |
| 2.5.3 Label in Name | A | Accessible name contains visible label text | aria-label includes visible button/link text |
| 2.5.4 Motion Actuation | A | Functionality triggered by motion has UI component alternative | Shake to undo has undo button; tilt has alternative control |
| 2.5.7 Dragging Movements NEW 2.2 | AA | Drag-and-drop has single-pointer alternative | File upload has browse button; sortable list has move up/down buttons |
| 2.5.8 Target Size (Minimum) NEW 2.2 | AA | Target size at least 24×24 CSS pixels (with exceptions) | Buttons, links, inputs minimum 24px; ideally 44px for touch |
| 3.1.1 Language of Page | A | Default language programmatically determined | <html lang="en"> attribute present |
| 3.1.2 Language of Parts | AA | Language of passages in different language programmatically determined | lang attribute on elements with different language |
| 3.2.1 On Focus | A | Receiving focus doesn't initiate change of context | Focus doesn't trigger navigation, form submission, or popups |
| 3.2.2 On Input | A | Changing settings doesn't automatically cause change of context | Form inputs don't auto-submit; changes require explicit action |
| 3.2.3 Consistent Navigation | AA | Navigation mechanisms repeated in same order | Menu order consistent across pages |
| 3.2.4 Consistent Identification | AA | Components with same functionality identified consistently | Icons, buttons with same function labeled consistently |
| 3.2.6 Consistent Help NEW 2.2 | A | Help mechanism in same relative order across pages | Help link/button appears in consistent location |
| 3.3.1 Error Identification | A | Input errors automatically detected and described | Form validation errors clearly identified and explained |
| 3.3.2 Labels or Instructions | A | Labels or instructions provided for user input | All form fields have visible labels or instructions |
| 3.3.3 Error Suggestion | AA | Suggestions provided when input error detected | Error messages include how to fix (e.g., "Enter date as MM/DD/YYYY") |
| 3.3.4 Error Prevention | AA | Submissions are reversible, checked, or confirmed for legal/financial data | Confirm before submitting important forms; allow review/edit |
| 3.3.7 Redundant Entry NEW 2.2 | A | Don't ask for same information twice unless necessary | Auto-populate previously entered data; use autocomplete |
| 3.3.8 Accessible Authentication (Minimum) NEW 2.2 | AA | Cognitive function test not required unless alternative provided | Provide password managers, email links, biometrics instead of CAPTCHA/recall |
| 4.1.2 Name, Role, Value | A | All UI components have programmatically determined name, role, states | Use semantic HTML or proper ARIA; verify in accessibility tree |
| 4.1.3 Status Messages | AA | Status messages can be programmatically determined without focus | Use role="status", role="alert", or aria-live for dynamic updates |
WCAG 2.2 New Success Criteria:
- 2.4.11 Focus Not Obscured (Minimum): Sticky headers and footers must not completely hide focused elements
- 2.5.7 Dragging Movements: All drag-and-drop must have keyboard/single-pointer alternative
- 2.5.8 Target Size (Minimum): 24×24px minimum (down from 44×44px in 2.1 AAA)
- 3.2.6 Consistent Help: Help mechanisms in same location across pages
- 3.3.7 Redundant Entry: Don't ask users to re-enter information already provided
- 3.3.8 Accessible Authentication (Minimum): No memory tests or puzzles for login
18.2 Section 508 Requirements
| Section | Requirement | Alignment with WCAG | Quick Check |
|---|---|---|---|
| § 1194.21(a) | Text equivalents for non-text elements | WCAG 1.1.1 Non-text Content | All images have alt text; form inputs have labels |
| § 1194.21(b) | Multimedia alternatives | WCAG 1.2.x (Captions, Audio Description) | Videos have captions and transcripts; audio has transcripts |
| § 1194.21(c) | Color is not sole method of conveying information | WCAG 1.4.1 Use of Color | Required fields use * plus "required"; errors use icon plus color |
| § 1194.21(d) | Documents organized to be readable without stylesheet | WCAG 1.3.1 Info and Relationships | Turn off CSS; content still logical and readable |
| § 1194.21(e) | Redundant text links for server-side image maps | Best practice (image maps rarely used now) | Use CSS/HTML instead of image maps; provide text alternatives |
| § 1194.21(f) | Client-side image maps with redundant text links | Best practice | Each clickable area has corresponding text link |
| § 1194.21(g) | Row and column headers for data tables | WCAG 1.3.1 Info and Relationships | Tables use <th> and scope or headers/id attributes |
| § 1194.21(h) | Markup for data table associations | WCAG 1.3.1 Info and Relationships | Complex tables use headers and id associations |
| § 1194.21(i) | Frames titled with text | WCAG 2.4.1 Bypass Blocks, 4.1.2 Name, Role, Value | Each <iframe> has unique title attribute |
| § 1194.21(j) | Pages with flicker-free design (2-55 Hz) | WCAG 2.3.1 Three Flashes | No content flashes more than 3 times per second |
| § 1194.21(k) | Text-only alternative if compliance cannot be met | Last resort (prefer fixing main content) | Provide accessible alternative version if absolutely necessary |
| § 1194.21(l) | Forms allow AT to access information and submit | WCAG 1.3.1, 3.3.2, 4.1.2 | All inputs labeled; keyboard accessible; screen reader compatible |
| § 1194.22(a) | Skip navigation links | WCAG 2.4.1 Bypass Blocks | "Skip to main content" link or proper landmarks |
| § 1194.22(b) | Color not sole indicator | WCAG 1.4.1 Use of Color | Use patterns, text, icons in addition to color |
| § 1194.22(c) | Style sheets used properly | WCAG 1.3.1 Info and Relationships | Content readable and functional without CSS |
| § 1194.22(n) | Forms completed and submitted via keyboard | WCAG 2.1.1 Keyboard | Tab through all fields; submit with Enter |
Section 508 Refresh (2017): Section 508 was updated in 2017 to incorporate WCAG 2.0 Level AA by
reference. This means federal agencies must comply with WCAG 2.0 AA (and by extension, WCAG 2.1/2.2 AA is
recommended). The specific technical requirements (§1194.21-22) above are from the original standards but are
still useful for understanding specific compliance aspects.
18.3 ADA Compliance Essentials
| ADA Title | Application to Web | Legal Standard | Practical Implementation |
|---|---|---|---|
| Title I | Employment | Employer websites, application portals, HR systems must be accessible | Job application forms accessible; employee portals keyboard-navigable |
| Title II | State & Local Government | All government websites and online services must be accessible | Public sector sites must meet WCAG 2.1 AA minimum |
| Title III | Public Accommodations | Places of public accommodation websites must be accessible (e.g., retail, entertainment) | E-commerce, restaurants, hotels, theaters, banks must be accessible |
| Title IV | Telecommunications | Telecommunications relay services | Video calling apps should support relay services; captions for communications |
| Title V | Miscellaneous | Retaliation and coercion prohibited | Cannot discriminate against users requesting accessibility accommodations |
Example: ADA Compliance Checklist
// ADA Web Accessibility Checklist
1. **Legal Basis:**
- ADA doesn't explicitly mention websites
- Courts have interpreted Title III to include websites
- DOJ references WCAG 2.1 Level AA as standard
- Follow WCAG 2.2 AA for best protection
2. **Who Must Comply:**
✓ Federal government (required by Section 508)
✓ State and local government (Title II)
✓ Public accommodations (Title III):
- Retail stores and online shops
- Hotels and booking sites
- Restaurants and food delivery
- Banks and financial services
- Healthcare providers
- Educational institutions
- Entertainment venues
✓ Employers with 15+ employees (Title I)
3. **Compliance Requirements:**
□ Meet WCAG 2.1 AA minimum (2.2 AA recommended)
□ Provide alternative means of access if needed
□ Respond to accommodation requests
□ Document accessibility efforts
□ Regular testing and monitoring
□ Staff training on accessibility
4. **Risk Mitigation:**
✓ Accessibility statement on website
✓ Feedback mechanism for reporting issues
✓ Remediation plan for identified issues
✓ Regular audits (annual minimum)
✓ Third-party testing
✓ User testing with people with disabilities
✓ Documentation of compliance efforts
5. **Common Lawsuit Triggers:**
- Missing alt text on images
- Keyboard navigation failures
- Poor color contrast
- Unlabeled form fields
- Inaccessible PDF documents
- Videos without captions
- CAPTCHA without alternative
- Inaccessible checkout process
6. **Safe Harbor Provisions:**
- Section 508: Agencies have 6 months to remediate
- No federal "safe harbor" for private sector
- Proactive compliance is best defense
- Document good-faith efforts
Legal Disclaimer: This is not legal advice. ADA web accessibility law is evolving. Courts have
issued varying rulings. DOJ has not published final web accessibility regulations for Title III (as of 2024).
Consult with legal counsel for specific compliance requirements. Follow WCAG 2.2 AA as best practice and strong
legal defense.
18.4 European EN 301 549 Standards
| EN 301 549 Clause | Requirement | WCAG Alignment | Application |
|---|---|---|---|
| 9.x Web Content | Web content must meet WCAG 2.1 Level AA | Direct reference to WCAG 2.1 AA | All public sector websites and apps (EU Directive 2016/2102) |
| 10.x Non-web Documents | Documents (PDFs, Word, etc.) must be accessible | Adapted WCAG criteria for documents | Use tagged PDFs; proper heading structure; alt text in documents |
| 11.x Software | Native applications must be accessible | Platform-specific guidelines based on WCAG principles | Desktop and mobile apps must be keyboard-accessible, screen-reader compatible |
| 12.x Documentation and Support | Product documentation must be accessible | WCAG 2.1 AA for online docs | Help files, user guides, support portals must be accessible |
| 5.1.3 Biometrics | Biometric authentication has alternative | Extended beyond WCAG | Fingerprint login must have password alternative |
| 5.1.4 Visual Alternatives | Visual information has non-visual alternative | WCAG 1.1.1, 1.3.3 | Status LEDs announced; visual alerts have sound/text |
| 5.2 Activation of Features | Accessibility features operable without assistance | Extended requirement | Users can enable screen reader, magnification independently |
EU Web Accessibility Directive (2016/2102):
- Scope: Public sector bodies (government, educational institutions, public healthcare)
- Standard: EN 301 549 which incorporates WCAG 2.1 Level AA
- Deadline: September 2020 for existing websites; June 2021 for mobile apps
- Requirements: Accessibility statement on every site; feedback mechanism; monitoring and enforcement
- Exemptions: Some archives, third-party content (if not funded/developed by public sector), extranets/intranets (until 2024)
- Private Sector: European Accessibility Act (EAA) applies to private sector from June 2025
Example: EN 301 549 Compliance Statement Template
<!-- Accessibility Statement (Required by EU Directive) -->
<section>
<h1>Accessibility Statement</h1>
<p>
[Organization Name] is committed to ensuring digital accessibility
for people with disabilities. We are continually improving the user
experience for everyone and applying the relevant accessibility standards.
</p>
<h2>Conformance Status</h2>
<p>
The <a href="https://www.w3.org/WAI/WCAG21/quickref/">Web Content
Accessibility Guidelines (WCAG)</a> defines requirements for designers
and developers to improve accessibility for people with disabilities.
</p>
<p>
This website is <strong>[fully/partially] conformant</strong> with
WCAG 2.1 level AA. [Fully conformant means that the content fully
conforms to the accessibility standard without any exceptions.
Partially conformant means that some parts do not fully conform.]
</p>
<h2>Feedback</h2>
<p>
We welcome your feedback on the accessibility of this website.
Please contact us if you encounter accessibility barriers:
</p>
<ul>
<li>Email: <a href="mailto:accessibility@example.com">
accessibility@example.com</a></li>
<li>Phone: +XX XXX XXX XXXX</li>
<li>Postal: [Address]</li>
</ul>
<p>We aim to respond within [X business days].</p>
<h2>Technical Specifications</h2>
<p>
Accessibility of this website relies on the following technologies:
</p>
<ul>
<li>HTML</li>
<li>CSS</li>
<li>JavaScript</li>
<li>WAI-ARIA</li>
</ul>
<h2>Assessment Approach</h2>
<p>
[Organization Name] assessed the accessibility of this website
by the following approaches:
</p>
<ul>
<li>Self-evaluation</li>
<li>External evaluation</li>
<li>Automated testing</li>
<li>User testing with people with disabilities</li>
</ul>
<h2>Date</h2>
<p>
This statement was created on <time>[Date]</time> using the
<a href="https://www.w3.org/WAI/planning/statements/">W3C Accessibility
Statement Generator</a>.
</p>
<p>Last reviewed on: <time>[Date]</time></p>
</section>
18.5 Documentation and Reporting
| Document Type | Purpose | Key Components | Update Frequency |
|---|---|---|---|
| Accessibility Statement | Public declaration of accessibility commitment and status | Conformance level, known issues, contact info, last review date | Annually or when significant changes made |
| VPAT (Voluntary Product Accessibility Template) | Detailed compliance report for procurement | WCAG 2.x criteria checklist, Section 508, EN 301 549, support levels | Per product version or major update |
| ACR (Accessibility Conformance Report) | Formal compliance documentation | VPAT-based report, testing methodology, known issues, remediation plan | Per product release |
| Accessibility Audit Report | Internal/external assessment of current state | Issues found, severity ratings, remediation recommendations, timeline | Quarterly or bi-annually |
| Remediation Plan | Action plan for fixing accessibility issues | Prioritized issues, owners, timelines, success criteria, resources needed | Updated as issues resolved |
| Test Results Documentation | Record of testing activities and outcomes | Test date, tools used, browsers/SRs tested, pass/fail results, screenshots | Per testing cycle (sprint, release) |
| User Feedback Log | Track accessibility issues reported by users | Issue description, reporter contact, date reported, resolution, date resolved | Continuous, reviewed monthly |
Example: VPAT/ACR Structure (Simplified)
// VPAT® (Voluntary Product Accessibility Template)
// Format: WCAG 2.x Edition
Product Information:
Name: [Product Name]
Version: [Version Number]
Date: [Report Date]
Contact: [Contact Information]
Evaluation Methods:
- Automated testing: axe DevTools, Lighthouse
- Manual testing: Keyboard navigation, screen readers
- Screen Readers: NVDA 2024.1, JAWS 2024, VoiceOver macOS 14
- Browsers: Chrome 120, Firefox 121, Safari 17, Edge 120
Conformance Level:
[X] Supports (fully meets the criterion)
[ ] Partially Supports (some functionality meets the criterion)
[ ] Does Not Support (does not meet the criterion)
[ ] Not Applicable
Table 1: WCAG 2.2 Level A Success Criteria
┌─────────────────────────────────────────────────────────┐
│ Criteria │ Conformance │ Remarks │
├─────────────────────────────────────────────────────────┤
│ 1.1.1 │ Supports │ All images have alt text │
│ 1.2.1 │ Supports │ Transcripts provided │
│ 1.2.2 │ Supports │ Captions on all videos │
│ 1.3.1 │ Supports │ Semantic HTML throughout │
│ 2.1.1 │ Partially │ Some custom widgets need work │
│ 2.1.2 │ Supports │ No keyboard traps detected │
│ ... │ ... │ ... │
└─────────────────────────────────────────────────────────┘
Table 2: WCAG 2.2 Level AA Success Criteria
[Similar structure]
Known Issues:
1. Custom dropdown keyboard navigation (2.1.1)
- Impact: High
- Remediation: Planned for v2.1
- Timeline: Q2 2024
2. Some charts lack text alternatives (1.1.1)
- Impact: Medium
- Remediation: In progress
- Timeline: Q1 2024
Legal Disclaimer:
This document is provided for informational purposes only.
[Organization] makes no representations regarding accuracy or
compliance with any specific regulations.
Example: Accessibility Audit Report Template
// Accessibility Audit Report
Executive Summary:
- Audit Date: [Date]
- Pages Tested: [Number]
- Overall Status: [Pass/Fail/Partial]
- Critical Issues: [Number]
- WCAG Level: AA
- Conformance: [XX]%
Methodology:
1. Automated Testing:
- Tool: axe DevTools
- Pages scanned: 50
- Issues found: 127
2. Manual Testing:
- Keyboard navigation: All pages
- Screen reader: NVDA + Firefox
- Color contrast: All text elements
- Zoom testing: 200% and 400%
3. User Testing:
- 5 users with disabilities
- Tasks: Navigation, form completion, checkout
- Success rate: 80%
Issues Summary by Severity:
Critical (Must Fix - WCAG A Violations):
1. Missing form labels - 15 instances
Impact: Screen reader users cannot complete forms
WCAG: 1.3.1, 3.3.2, 4.1.2
Remediation: Add <label> elements with for attributes
Effort: 4 hours
Priority: 1
2. Keyboard traps in modal - 3 instances
Impact: Keyboard users cannot exit modals
WCAG: 2.1.2
Remediation: Implement focus trap with Esc key
Effort: 8 hours
Priority: 1
High (Should Fix - WCAG AA Violations):
3. Insufficient color contrast - 47 instances
Impact: Low vision users cannot read text
WCAG: 1.4.3
Remediation: Darken text or lighten backgrounds
Effort: 12 hours
Priority: 2
Medium (Recommended - Best Practices):
4. Non-descriptive link text - 23 instances
Impact: Reduced context for screen reader users
WCAG: 2.4.4
Remediation: Update link text to be descriptive
Effort: 6 hours
Priority: 3
Recommendations:
1. Immediate: Fix all Critical issues (estimated 2 weeks)
2. Short-term: Address High priority items (estimated 1 month)
3. Long-term: Implement accessibility testing in CI/CD
4. Ongoing: Train development team on accessibility
Next Steps:
- Create Jira tickets for all issues
- Assign owners and deadlines
- Schedule follow-up audit in 3 months
- Update accessibility statement
Documentation Best Practices:
- Accessibility Statement: Required by EU law; recommended globally; update annually; include feedback mechanism
- VPAT/ACR: Essential for government/enterprise sales; use official VPAT template; be honest about limitations
- Internal Documentation: Track all testing, issues, and fixes; maintain remediation timeline; document decisions
- Version Control: Keep historical records of accessibility improvements; tag releases with conformance status
- Transparency: Be open about known issues; provide workarounds; commit to timelines for fixes
- Continuous Improvement: Regular audits; user feedback; automated testing in CI/CD; team training
- Legal Protection: Documentation demonstrates good-faith effort; shows commitment to accessibility; useful in litigation
Legal Compliance Quick Reference Summary
- WCAG 2.2 AA: International standard; 50+ success criteria; new criteria for focus visibility, target size, authentication; test with automated tools + manual review
- Section 508: US federal requirement; now references WCAG 2.0 AA; applies to government websites and contractors; similar to WCAG compliance
- ADA: US civil rights law; Title II (government), Title III (public accommodations); no explicit web standard but courts reference WCAG 2.1 AA; active litigation area
- EN 301 549: European standard; incorporates WCAG 2.1 AA; required for EU public sector; European Accessibility Act extends to private sector June 2025
- Documentation: Accessibility statement (required EU, recommended globally); VPAT/ACR for procurement; audit reports; remediation plans; user feedback logs
- Risk Mitigation: Follow WCAG 2.2 AA minimum; document compliance efforts; regular testing; feedback mechanism; staff training; user testing
- Key Principles: Perceivable, Operable, Understandable, Robust (POUR); semantic HTML first; keyboard accessibility; screen reader compatibility
- Resources: W3C WAI, WCAG Quick Reference, ARIA APG, WebAIM, Deque University, accessibility testing tools