Screen Reader Architecture and the Accessibility Tree
Understanding screen reader internals informs better markup decisions.
When a page loads, the screen reader builds an accessibility tree, a parallel representation of the DOM that strips out visual-only information and exposes semantic structure. The browser performs the mapping from HTML elements to accessibility roles:
DOM Element → Accessibility Role
<button> → button
<a href="..."> → link
<h1> → heading (level 1)
<input type="text"> → textbox
<div> → generic (ignored by most SR)
<nav> → navigation
<main> → main
<ul><li> → list → listitem
The screen reader navigates this tree, not the DOM. When a user presses the down arrow in NVDA, it moves to the next node in the accessibility tree. When they press H, it jumps to the next heading. When they press F, it jumps to the next form field.
This is why semantic HTML matters so much. A <div onclick="..."> has no accessibility role. It's invisible in the tree. A <button> has a role, a name, and built-in keyboard handling.
<!-- BAD: invisible to screen readers without extensive ARIA -->
<div class="btn" onclick="handleClick()">
Submit
</div>
<!-- STILL BAD: role without keyboard support -->
<div class="btn" role="button" onclick="handleClick()">
Submit
</div>
<!-- ACCEPTABLE: role + keyboard + tabindex -->
<div class="btn" role="button" tabindex="0"
onclick="handleClick()"
onkeydown="if(event.key==='Enter'||event.key===' '){handleClick()}">
Submit
</div>
<!-- GOOD: just use a button -->
<button class="btn" onclick="handleClick()">
Submit
</button>
ARIA Live Regions: Making Dynamic Content Accessible
ARIA live regions tell screen readers to monitor a section of the DOM for changes and announce them. This is essential for SPAs where content updates without a page reload.
<!-- Status messages: polite, waits for current speech to finish -->
<div id="search-status" aria-live="polite" aria-atomic="true">
<!-- Content updated via JS -->
</div>
<!-- Error messages: assertive, interrupts immediately -->
<div id="error-banner" role="alert" aria-live="assertive">
<!-- Critical errors go here -->
</div>
<!-- Chat messages: polite with aria-relevant for additions only -->
<div id="chat-messages"
aria-live="polite"
aria-relevant="additions"
aria-label="Chat messages">
<!-- New messages appended here -->
</div>
The critical attributes:
// live_regions.js - Proper live region implementation
class LiveAnnouncer {
constructor() {
this.politeRegion = this.createRegion('polite');
this.assertiveRegion = this.createRegion('assertive');
document.body.appendChild(this.politeRegion);
document.body.appendChild(this.assertiveRegion);
}
createRegion(politeness) {
const region = document.createElement('div');
region.setAttribute('aria-live', politeness);
region.setAttribute('aria-atomic', 'true');
// Visually hidden but accessible to screen readers
Object.assign(region.style, {
position: 'absolute',
width: '1px',
height: '1px',
padding: '0',
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: '0',
});
return region;
}
announce(message, priority = 'polite') {
const region = priority === 'assertive'
? this.assertiveRegion
: this.politeRegion;
// Clear and re-set to trigger announcement
// Some screen readers ignore identical content
region.textContent = '';
requestAnimationFrame(() => {
region.textContent = message;
});
}
}
// Usage
const announcer = new LiveAnnouncer();
// Search results updated
announcer.announce('23 results found for "accessibility"');
// Form validation error
announcer.announce('Email address is invalid. Please check the format.', 'assertive');
// Item added to cart
announcer.announce('Blue t-shirt added to cart. Cart total: $45.99');
The requestAnimationFrame trick is important. Some screen readers (especially VoiceOver) don't announce changes if the content is set in the same frame it was cleared. The RAF ensures the clear and set happen in separate frames.
Focus Management: The Invisible UX
Focus management is the most impactful and most neglected aspect of web accessibility. When focus is managed poorly, keyboard and screen reader users get lost.
// focus_management.js - SPA route change handler
class FocusManager {
constructor() {
this.previousFocus = null;
}
// Call after SPA route change
handleRouteChange(pageTitle) {
// Update document title
document.title = pageTitle;
// Find the main heading
const mainHeading = document.querySelector('main h1');
if (mainHeading) {
// Make it focusable if it isn't
if (!mainHeading.hasAttribute('tabindex')) {
mainHeading.setAttribute('tabindex', '-1');
}
// Move focus to the heading
mainHeading.focus();
} else {
// Fallback: focus the main landmark
const main = document.querySelector('main');
if (main) {
main.setAttribute('tabindex', '-1');
main.focus();
}
}
}
// Modal focus trap
trapFocus(modalElement) {
this.previousFocus = document.activeElement;
const focusableElements = modalElement.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstFocusable = focusableElements[0];
const lastFocusable = focusableElements[focusableElements.length - 1];
// Focus first element
firstFocusable?.focus();
// Trap tab within modal
const trapHandler = (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === firstFocusable) {
e.preventDefault();
lastFocusable?.focus();
}
} else {
if (document.activeElement === lastFocusable) {
e.preventDefault();
firstFocusable?.focus();
}
}
};
modalElement.addEventListener('keydown', trapHandler);
// Return cleanup function
return () => {
modalElement.removeEventListener('keydown', trapHandler);
this.previousFocus?.focus();
};
}
}
Automated Testing with axe-core
axe-core is the standard for automated accessibility testing. The following example demonstrates integration with Playwright:
// axe-ci.js - axe-core integration for CI/CD
import { AxeBuilder } from '@axe-core/playwright';
import { test, expect } from '@playwright/test';
// Test every page
const pages = [
{ url: '/', name: 'Home' },
{ url: '/products', name: 'Products' },
{ url: '/products/1', name: 'Product Detail' },
{ url: '/cart', name: 'Cart' },
{ url: '/checkout', name: 'Checkout' },
];
for (const page of pages) {
test(`accessibility: ${page.name}`, async ({ page: pwPage }) => {
await pwPage.goto(page.url);
// Wait for dynamic content to load
await pwPage.waitForLoadState('networkidle');
const results = await new AxeBuilder({ page: pwPage })
.withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
.exclude('.third-party-widget') // Exclude content you don't control
.analyze();
// Detailed failure reporting
const violations = results.violations.map(v => ({
rule: v.id,
impact: v.impact,
description: v.description,
nodes: v.nodes.map(n => ({
html: n.html.substring(0, 200),
target: n.target,
failureSummary: n.failureSummary,
}))
}));
if (violations.length > 0) {
console.error(`Accessibility violations on ${page.name}:`);
violations.forEach(v => {
console.error(` [${v.impact}] ${v.rule}: ${v.description}`);
v.nodes.forEach(n => {
console.error(` Element: ${n.target}`);
console.error(` Fix: ${n.failureSummary}`);
});
});
}
expect(violations).toEqual([]);
});
}
// Test interactive states
test('accessibility: modal dialog', async ({ page }) => {
await page.goto('/products/1');
await page.click('[data-testid="quick-view"]');
// Wait for modal animation
await page.waitForSelector('[role="dialog"]', { state: 'visible' });
const results = await new AxeBuilder({ page })
.include('[role="dialog"]')
.analyze();
expect(results.violations).toEqual([]);
// Also verify focus is trapped in modal
const focusedElement = await page.evaluate(() =>
document.activeElement?.closest('[role="dialog"]') !== null
);
expect(focusedElement).toBe(true);
});
A Four-Phase Accessibility Audit Methodology
A proven four-phase approach covers the full spectrum of accessibility issues:
Phase 1: Automated Scan (catches ~30% of issues)
- Run axe-core on every page and interactive state
- Check color contrast with automated tools
- Validate HTML (invalid HTML breaks screen readers)
Phase 2: Keyboard Navigation (catches ~25% of issues)
- Tab through every page, verify visible focus indicator
- Test all interactive elements with Enter, Space, Escape, Arrow keys
- Verify focus order matches visual order
- Test skip navigation links
- Verify modal focus trapping
Phase 3: Screen Reader Testing (catches ~35% of issues)
- Navigate with NVDA/VoiceOver in forms mode and browse mode
- Verify all images have meaningful alt text (not just any alt text)
- Verify dynamic content is announced
- Test custom widgets (date pickers, accordions, tabs)
- Verify error messages are associated with form fields
Phase 4: Cognitive and Motor (catches ~10% of issues)
- Test with 200% zoom
- Test with motion reduced (prefers-reduced-motion)
- Verify touch targets are at least 44x44px
- Test with voice control (Dragon NaturallySpeaking or Voice Control on macOS)
# accessibility_audit.sh - Audit automation script
#!/bin/bash
set -euo pipefail
BASE_URL="${1:-http://localhost:3000}"
REPORT_DIR="./a11y-reports/$(date +%Y%m%d)"
mkdir -p "$REPORT_DIR"
echo "Running accessibility audit against $BASE_URL"
# Phase 1: axe-core scan
npx playwright test tests/accessibility/ \
--reporter=json \
--output "$REPORT_DIR/axe-results.json" \
2>&1 | tee "$REPORT_DIR/axe-output.log"
# Phase 1b: HTML validation
npx html-validate "$BASE_URL" \
--formatter json \
> "$REPORT_DIR/html-validation.json" 2>&1 || true
# Phase 1c: Lighthouse accessibility score
npx lighthouse "$BASE_URL" \
--only-categories=accessibility \
--output=json \
--output-path="$REPORT_DIR/lighthouse.json" \
--chrome-flags="--headless --no-sandbox" \
2>&1 || true
echo "Automated scans complete. Reports saved to $REPORT_DIR"
echo "Remember: automated tests catch ~30% of issues."
echo "Proceed with manual keyboard and screen reader testing."
Lessons from Notification Accessibility Failures
When a screen reader user reports invisible notifications, the root cause is almost always ad-hoc DOM injection without accessibility consideration. Three changes address this pattern effectively:
-
Every dynamic content update should go through a centralized
LiveAnnouncerservice. No more ad-hoc DOM injection without accessibility consideration. -
Screen reader testing belongs in the QA checklist. Not "check the axe scan", actual NVDA testing of every new feature.
-
Engaging a consultant who is a daily screen reader user for quarterly reviews provides irreplaceable perspective. Someone who actually depends on accessibility technology catches issues that sighted testers consistently miss.
Automated tests are necessary but insufficient. Manual testing is time-consuming but essential. User feedback is what makes the difference between compliance and actual accessibility. A feature that fails one automated check but works perfectly with a screen reader is more valuable than one that passes every automated scan but leaves real users stranded.