JavaScript Accessibility APIs

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.

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.

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.

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.

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).

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)