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>&copy; 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>

❌ Incorrect Hierarchy

<h1>Page Title</h1>
<h4>Skipped levels</h4>
<h2>Out of order</h2>

✅ Correct Hierarchy

<h1>Page Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>

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

  1. aria-labelledby (highest)
  2. aria-label
  3. <label> element
  4. placeholder (input only)
  5. Element content text
  6. title attribute (lowest)
Common Mistakes:
  • Using aria-label on <div> without role
  • Empty aria-label="" (use aria-hidden)
  • Conflicting labels (both aria-label and labelledby)
  • 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>&copy; 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.
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
<!-- 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-visible instead
  • 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 inert attribute 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-describedby for help text, aria-labelledby for 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 required attribute + visual indicator (*, text)
  • Custom controls need proper ARIA roles, states, and keyboard interaction
  • Provide clear feedback during and after submission with aria-live regions
  • 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 - add type="button" to prevent form submission
  • Toggle buttons need aria-pressed, menu buttons need aria-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 alt attribute - use alt="" 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-label for informative
  • Icon-only buttons must have aria-label - hide icon with aria-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-scheme and 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=no or maximum-scale=1
  • Reflow: Content must reflow at 320px width without horizontal scrolling (WCAG 1.4.10)
  • Focus: Use :focus-visible for 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: more for 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: auto for reduced motion; use scroll-margin-top for sticky headers; ensure scroll-snap doesn'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 speech
Insert+Down: Read all
H/Shift+H: Headings
K/Shift+K: Links
D/Shift+D: Landmarks
B/Shift+B: Buttons
F/Shift+F: Form fields
Heading structure, landmarks, form labels, ARIA announcements
Windows JAWS (commercial) Insert+F5: Form fields list
Insert+F6: Headings list
Insert+F7: Links list
R: Regions/landmarks
Insert+F3: Elements list
Complex ARIA widgets, forms, tables, dynamic content
macOS VoiceOver Cmd+F5: Toggle VO
VO+A: Read all
VO+Right/Left: Navigate
VO+U: Rotor menu
VO+Space: Activate
VO+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:
  1. Automated scan: Run Lighthouse, axe DevTools, or WAVE to catch obvious issues
  2. Keyboard test: Navigate entire page with Tab/Shift+Tab, Enter, Spacebar, arrows - no mouse
  3. Screen reader test: Test with NVDA (Windows), VoiceOver (Mac), or TalkBack (Android)
  4. Visual inspection: Check contrast, focus indicators, zoom to 200%, test dark mode
  5. Manual testing: Follow WCAG checklist for manual checks (color not sole indicator, etc.)
  6. 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.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
  • 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