11 - Accessibility ​
1. Accessibility Tree ​
What: A parallel tree to the DOM that the browser constructs for assistive technologies (screen readers, switch devices, voice control). Only semantically meaningful information — no visual styling, no <div> soup.
DOM Tree: Accessibility Tree:
<div class="card"> (no role, ignored)
<h2>Payment</h2> heading "Payment" (level 2)
<div class="form"> (no role, ignored)
<label>Amount</label> (associated with input)
<input type="number"> spinbutton "Amount"
<button>Pay Now</button> button "Pay Now"
</div>
<div aria-hidden="true"> (REMOVED from a11y tree entirely)
<img src="decoration.svg">
</div>
</div>What creates accessible nodes:
- Semantic HTML:
<button>,<input>,<h1>-<h6>,<nav>,<main>, etc. - ARIA roles:
role="dialog",role="alert",role="tab", etc. - ARIA properties:
aria-label,aria-describedby,aria-expanded, etc.
What gets ignored:
<div>and<span>without roles or ARIAaria-hidden="true"elements (and their children)display: none/visibility: hiddenelements
React Testing Library uses the accessibility tree:
// getByRole queries the accessibility tree, NOT the DOM
screen.getByRole('button', { name: /pay now/i });
screen.getByRole('spinbutton', { name: /amount/i });
screen.getByRole('heading', { level: 2 });Inspecting the accessibility tree:
- Chrome DevTools → Elements → Accessibility pane
- Chrome DevTools → Rendering → "Enable accessibility inspection"
- Firefox → Accessibility Inspector (dedicated tool)
Why semantic HTML matters:
// BAD: Looks like a button, acts like nothing
<div className="btn" onClick={handleClick}>Pay Now</div>
// Not in a11y tree as button, no keyboard support, no focus, no announce
// GOOD: Is a button
<button onClick={handleClick}>Pay Now</button>
// In a11y tree, focusable, Enter/Space triggers it, screen reader announces it2. ARIA Live Regions Internals ​
What: ARIA live regions announce dynamic content changes to screen readers without the user needing to navigate to the changed element.
When content changes in a live region, the screen reader interrupts (or queues) to announce the change.
aria-live="polite" ​
// Announces change AFTER screen reader finishes current speech
<div aria-live="polite">
{successMessage && <p>{successMessage}</p>}
</div>
// User hears: "...current content... [pause] Payment successful"aria-live="assertive" ​
// INTERRUPTS current speech immediately
<div aria-live="assertive">
{error && <p>{error}</p>}
</div>
// User hears: "Error: Card declined" (interrupts whatever was being read)role="status" (implicit aria-live="polite") ​
<div role="status">
{isLoading ? 'Processing payment...' : 'Payment complete'}
</div>role="alert" (implicit aria-live="assertive") ​
<div role="alert">
{error && <p>{error}</p>}
</div>How live regions work internally:
- Browser monitors the live region's subtree for DOM mutations
- When content changes (text, children added/removed), the browser calculates the "announcement" (new text content)
- The announcement is sent to the screen reader via the platform accessibility API
- Screen reader speaks it based on priority (polite = queue, assertive = interrupt)
Critical gotchas:
- The live region must EXIST BEFORE content changes:
// BAD: Region created with content — no announcement
{error && <div role="alert">{error}</div>}
// The div doesn't exist, then appears with content → some screen readers miss it
// GOOD: Region always exists, content changes inside it
<div role="alert">
{error && <p>{error}</p>}
</div>
// The div always exists, the <p> appears inside → announced- aria-atomic:
// aria-atomic="true" — announces the ENTIRE region on any change
<div aria-live="polite" aria-atomic="true">
<span>Items in cart: </span>
<span>{count}</span> {/* When count changes, announces "Items in cart: 5" */}
</div>
// aria-atomic="false" (default) — only announces the changed part
<div aria-live="polite">
<span>Items in cart: </span>
<span>{count}</span> {/* When count changes, only announces "5" */}
</div>- aria-relevant:
<div aria-live="polite" aria-relevant="additions removals">
{/* Announces when items are ADDED or REMOVED */}
{items.map(item => <p key={item.id}>{item.name}</p>)}
</div>
// Values: additions, removals, text, all (default: additions text)Common patterns (payments/fintech):
// Payment status announcements
function PaymentStatus({ status }: { status: PaymentStatus }) {
return (
<div role="status" aria-live="polite">
{status.status === 'processing' && 'Processing your payment...'}
{status.status === 'success' && `Payment successful. Transaction ID: ${status.transactionId}`}
</div>
);
}
// Error announcements
function FormErrors({ errors }: { errors: string[] }) {
return (
<div role="alert" aria-live="assertive">
{errors.length > 0 && (
<ul>
{errors.map((error, i) => <li key={i}>{error}</li>)}
</ul>
)}
</div>
);
}
// Loading state
function LoadingAnnouncer({ isLoading }: { isLoading: boolean }) {
return (
<div role="status" aria-live="polite" className="sr-only">
{isLoading ? 'Loading...' : 'Content loaded'}
</div>
);
}sr-only class (screen reader only — visually hidden but announced):
.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;
}