Skip to content

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 ARIA
  • aria-hidden="true" elements (and their children)
  • display: none / visibility: hidden elements

React Testing Library uses the accessibility tree:

tsx
// 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:

tsx
// 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 it

2. 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" ​

tsx
// 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" ​

tsx
// 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") ​

tsx
<div role="status">
  {isLoading ? 'Processing payment...' : 'Payment complete'}
</div>

role="alert" (implicit aria-live="assertive") ​

tsx
<div role="alert">
  {error && <p>{error}</p>}
</div>

How live regions work internally:

  1. Browser monitors the live region's subtree for DOM mutations
  2. When content changes (text, children added/removed), the browser calculates the "announcement" (new text content)
  3. The announcement is sent to the screen reader via the platform accessibility API
  4. Screen reader speaks it based on priority (polite = queue, assertive = interrupt)

Critical gotchas:

  1. The live region must EXIST BEFORE content changes:
tsx
// 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
  1. aria-atomic:
tsx
// 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>
  1. aria-relevant:
tsx
<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):

tsx
// 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):

css
.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;
}

Frontend interview preparation reference.