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)