Accessibility Tree: Browser ARIA Computation, Accessible Name Calculation, Platform API Mapping Internals
Accessibility Tree: Browser ARIA Computation, Accessible Name Calculation, Platform API Mapping Internals
Real-World Problem Context
Your React SPA scores perfectly on Lighthouse performance but a screen reader user reports that the custom dropdown is invisible, the modal doesn't trap focus, and live chat messages are never announced. You inspect the DOM — the HTML looks fine. But the accessibility tree the browser builds from your DOM tells a completely different story. The accessibility tree is a parallel tree structure that browsers construct from the DOM, stripping away visual-only elements and enriching semantic ones with roles, states, properties, and computed names. Screen readers, voice control software, and switch devices query this tree — not the DOM, not the rendered pixels. Understanding how the browser builds the accessibility tree, how it computes accessible names, and how ARIA attributes override or supplement native semantics is essential for building interfaces that actually work for assistive technology users.
Problem Statements
-
Tree Construction: How does the browser build the accessibility tree from the DOM, which elements are included or excluded, and how do
role,aria-*attributes, and native HTML semantics interact? -
Name Computation: How does the browser compute the accessible name for an element? What is the algorithm, and why does the order of precedence (
aria-labelledby>aria-label> native label > contents > title) matter? -
Platform Mapping and Live Regions: How does the accessibility tree get exposed to screen readers through platform accessibility APIs (MSAA/UIA, ATK/AT-SPI, NSAccessibility), and how do live regions (
aria-live) and focus management work in SPAs?
Deep Dive: Internal Mechanisms
1. The Accessibility Tree — Parallel to the DOM
// The DOM and Accessibility Tree are different structures:
// DOM:
<div class="card">
<img src="hero.jpg" alt="Product photo">
<div class="card-body">
<h3>Widget Pro</h3>
<span class="price">$49.99</span>
<div class="stars" aria-label="4.5 out of 5 stars">★★★★½</div>
<button class="buy-btn">
<svg>...</svg>
<span>Add to Cart</span>
</button>
</div>
</div>
// Accessibility Tree (simplified):
// group (div.card → generic role, but included because it has accessible children)
// img "Product photo" (role: img, name: "Product photo")
// group (div.card-body → generic)
// heading level 3 "Widget Pro"
// text "49.99" (span has no role, contents exposed as text)
// generic "4.5 out of 5 stars" (from aria-label)
// button "Add to Cart"
// (SVG is NOT in the tree — presentational)
// text "Add to Cart"
// Key differences from DOM:
// 1. CSS display:none / visibility:hidden → excluded entirely
// 2. aria-hidden="true" → excluded (and all descendants)
// 3. Presentational elements (role="presentation" or role="none") → removed
// 4. <div> and <span> → "generic" role (included if they have content/children)
// 5. Semantic HTML → mapped to roles automatically
// 6. The tree is FLAT-ish — deeply nested divs may be collapsed
2. Role Computation — How Elements Get Their Roles
// Every node in the accessibility tree has a ROLE.
// Role is determined by this precedence:
// 1. Explicit ARIA role attribute:
<div role="button">Click me</div> // role: button
<span role="tab">Settings</span> // role: tab
<div role="alert">Error occurred</div> // role: alert
// 2. Implicit role from HTML element (native semantics):
<button>Submit</button> // role: button (implicit)
<a href="/page">Link</a> // role: link
<input type="checkbox"> // role: checkbox
<nav> // role: navigation
<main> // role: main
<aside> // role: complementary
<header> // role: banner (when top-level)
<footer> // role: contentinfo (when top-level)
<table> // role: table
<ul>/<ol> // role: list
<li> // role: listitem
<select> // role: combobox (or listbox)
<h1>–<h6> // role: heading (with aria-level)
// 3. The role determines which ARIA states/properties are valid:
// button → aria-pressed, aria-expanded, aria-disabled
// checkbox → aria-checked (required!)
// tab → aria-selected, aria-controls
// combobox → aria-expanded, aria-activedescendant, aria-autocomplete
// Role categories:
// Widget roles: button, checkbox, slider, tab, textbox, switch, etc.
// Document structure: article, heading, list, row, cell, etc.
// Landmark: banner, navigation, main, complementary, contentinfo
// Live region: alert, log, status, timer, marquee
// Window: dialog, alertdialog
// IMPORTANT: role="presentation" and role="none" REMOVE semantics:
<table role="presentation"> // No longer a table for AT
<tr><td>Layout content</td></tr>
</table>
// Some roles CANNOT be overridden (strong native semantics):
// <a href="..."> always exposes as link
// <input type="text"> always exposes as textbox
// (Browsers may ignore role overrides on these)
3. Accessible Name Computation Algorithm (AccName)
// The accessible name is the TEXT label assistive technology announces.
// The W3C Accessible Name and Description Computation spec defines
// a strict precedence algorithm:
// Step 1: aria-labelledby (HIGHEST priority)
<div id="label1">First Name</div>
<div id="label2">(required)</div>
<input aria-labelledby="label1 label2" type="text">
// Name: "First Name (required)"
// aria-labelledby takes MULTIPLE space-separated IDs
// It concatenates their text content
// It can reference HIDDEN elements (aria-hidden or display:none)
// Step 2: aria-label
<button aria-label="Close dialog">✕</button>
// Name: "Close dialog" (NOT "✕")
// aria-label is a string override — ignores all content
// Step 3: Native labeling mechanism
// <label for="...">:
<label for="email">Email Address</label>
<input id="email" type="email">
// Name: "Email Address"
// <label> wrapping:
<label>
Email Address
<input type="email">
</label>
// Name: "Email Address"
// <caption> for <table>:
<table><caption>Sales Data</caption>...</table>
// Name: "Sales Data"
// <legend> for <fieldset>:
<fieldset><legend>Shipping Address</legend>...</fieldset>
// Name: "Shipping Address"
// <figcaption> for <figure>:
<figure><img src="chart.png" alt="Revenue"><figcaption>Q4 Revenue</figcaption></figure>
// alt attribute for <img>:
<img src="logo.png" alt="Company Logo">
// Name: "Company Logo"
// Step 4: Text content (for elements whose role allows name from content)
<button><svg aria-hidden="true">...</svg> Save Changes</button>
// Name: "Save Changes"
// Roles that allow name from content: button, link, heading, tab, cell, etc.
// Roles that DON'T: textbox, img (uses alt), group, etc.
// Step 5: title attribute (LOWEST priority — last resort)
<input type="text" title="Search query">
// Name: "Search query" (only if no other name source exists)
// title is unreliable — not exposed consistently by screen readers
// The algorithm is RECURSIVE:
<button aria-labelledby="btn-label">
<span id="btn-label">
<img alt="cart icon"> Add to <strong>Cart</strong>
</span>
</button>
// Name computation walks the subtree of #btn-label:
// "cart icon" + " Add to " + "Cart" = "cart icon Add to Cart"
4. Accessible Description and Other Text Properties
// Beyond name, elements have additional text properties:
// Accessible Description (secondary info announced after the name):
<input
type="password"
aria-label="Password"
aria-describedby="pw-req"
>
<div id="pw-req">Must be at least 8 characters with one number</div>
// Name: "Password"
// Description: "Must be at least 8 characters with one number"
// Screen reader: "Password, edit, Must be at least 8 characters with one number"
// aria-description (inline version — newer):
<button aria-description="Opens in new window">External Link</button>
// Error messages:
<input
type="email"
aria-invalid="true"
aria-errormessage="email-error"
>
<div id="email-error" role="alert">Please enter a valid email</div>
// Placeholder vs label:
<input type="email" placeholder="user@example.com" aria-label="Email">
// placeholder is NOT a substitute for a label
// It disappears when typing — not a reliable name source
// aria-label or <label> is required
// Computation priority for description:
// 1. aria-describedby
// 2. aria-description
// 3. title (if title wasn't used as the name)
// 4. (nothing)
5. States and Properties — Dynamic Accessibility
// ARIA states reflect current UI state to assistive technology:
// Expanded/collapsed (accordion, dropdown):
const toggle = document.querySelector("[aria-expanded]");
toggle.addEventListener("click", () => {
const expanded = toggle.getAttribute("aria-expanded") === "true";
toggle.setAttribute("aria-expanded", String(!expanded));
const panel = document.getElementById(
toggle.getAttribute("aria-controls")
);
panel.hidden = expanded;
});
// HTML:
// <button aria-expanded="false" aria-controls="panel1">Details</button>
// <div id="panel1" hidden>Panel content...</div>
// Checked state (custom checkbox):
// <div role="checkbox" aria-checked="false" tabindex="0">
function toggleCheckbox(el) {
const checked = el.getAttribute("aria-checked") === "true";
el.setAttribute("aria-checked", String(!checked));
// Screen reader announces: "Accept terms, checkbox, not checked"
// After toggle: "Accept terms, checkbox, checked"
}
// Selected (tabs):
// <div role="tablist">
// <button role="tab" aria-selected="true" aria-controls="panel1">Tab 1</button>
// <button role="tab" aria-selected="false" aria-controls="panel2">Tab 2</button>
// </div>
function selectTab(tab, allTabs) {
allTabs.forEach(t => t.setAttribute("aria-selected", "false"));
tab.setAttribute("aria-selected", "true");
// Manage associated panels...
}
// Disabled vs aria-disabled:
// <button disabled> → not focusable, not clickable (native)
// <button aria-disabled="true"> → announced as disabled BUT still focusable
// Use aria-disabled when you want to explain WHY it's disabled:
// <button aria-disabled="true" aria-describedby="why">Submit</button>
// <div id="why">Please fill in all required fields</div>
// Current (navigation):
// <nav>
// <a href="/" aria-current="page">Home</a>
// <a href="/about">About</a>
// </nav>
// Busy (loading states):
const container = document.getElementById("results");
container.setAttribute("aria-busy", "true");
// Fetch data...
container.setAttribute("aria-busy", "false");
// Now screen reader will read updated content
6. Live Regions — Dynamic Content Announcements
<!-- Live regions announce content changes WITHOUT focus moving there -->
<!-- aria-live="polite" — announces when screen reader is idle -->
<div aria-live="polite" id="search-status">
<!-- Initially empty or has initial text -->
</div>
<script>
// When search results update:
document.getElementById("search-status").textContent =
"42 results found for 'accessibility'";
// Screen reader will announce this text at the next pause
</script>
<!-- aria-live="assertive" — interrupts current announcement -->
<div aria-live="assertive" id="error-banner"></div>
<script>
// Critical error:
document.getElementById("error-banner").textContent =
"Session expired. Please log in again.";
// Screen reader announces IMMEDIATELY, interrupting other speech
</script>
<!-- Implicit live regions (no aria-live needed): -->
<div role="alert">Payment failed</div>
<!-- role="alert" implies aria-live="assertive" + aria-atomic="true" -->
<div role="status">File uploaded successfully</div>
<!-- role="status" implies aria-live="polite" + aria-atomic="true" -->
<output>Total: $49.99</output>
<!-- <output> implies role="status" → aria-live="polite" -->
<!-- aria-atomic — announce the ENTIRE region or just changes: -->
<div aria-live="polite" aria-atomic="true" id="cart-count">
Items in cart: <span>3</span>
</div>
<!-- When span changes to "4": -->
<!-- aria-atomic="true" → "Items in cart: 4" (whole region) -->
<!-- aria-atomic="false" → "4" (only changed node) -->
<!-- aria-relevant — what types of changes to announce: -->
<div aria-live="polite" aria-relevant="additions removals">
<!-- Only announce when nodes are added or removed -->
<!-- Default is "additions text" -->
</div>
<!-- Common mistake — adding aria-live dynamically: -->
<script>
// BAD: Adding aria-live AND content at the same time
const div = document.createElement("div");
div.setAttribute("aria-live", "polite");
div.textContent = "New message"; // May NOT be announced
document.body.appendChild(div);
// GOOD: aria-live region exists in DOM, content changes later
// <div aria-live="polite" id="messages"></div>
document.getElementById("messages").textContent = "New message"; // Announced
</script>
7. Focus Management in SPAs
// SPAs need manual focus management because the browser's
// natural page-navigation focus handling doesn't apply:
// 1. Route change — move focus to new content:
function onRouteChange(newContent) {
const main = document.querySelector("main");
main.innerHTML = newContent;
// Focus the heading of the new page:
const heading = main.querySelector("h1");
if (heading) {
heading.setAttribute("tabindex", "-1"); // Make focusable
heading.focus();
// Screen reader announces: "New Page Title, heading level 1"
}
}
// 2. Modal dialog — trap focus inside:
function openModal(modal) {
modal.showModal(); // Native <dialog> handles focus trap!
// Or manually:
modal.hidden = false;
modal.setAttribute("aria-modal", "true");
const focusableElements = modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstFocusable = focusableElements[0];
const lastFocusable = focusableElements[focusableElements.length - 1];
firstFocusable.focus();
modal.addEventListener("keydown", (e) => {
if (e.key === "Tab") {
if (e.shiftKey && document.activeElement === firstFocusable) {
e.preventDefault();
lastFocusable.focus();
} else if (!e.shiftKey && document.activeElement === lastFocusable) {
e.preventDefault();
firstFocusable.focus();
}
}
if (e.key === "Escape") {
closeModal(modal);
}
});
}
function closeModal(modal, triggerElement) {
modal.hidden = true;
modal.removeAttribute("aria-modal");
// CRITICAL: return focus to the element that opened the modal
triggerElement.focus();
}
// 3. Dynamic content — aria-activedescendant pattern:
// For composite widgets (listbox, tree, grid) where focus stays
// on the container but visual/AT focus moves among children:
// <div role="listbox" aria-activedescendant="option-2" tabindex="0">
// <div role="option" id="option-1">Apple</div>
// <div role="option" id="option-2" aria-selected="true">Banana</div>
// <div role="option" id="option-3">Cherry</div>
// </div>
function handleListboxKeydown(listbox, e) {
const options = [...listbox.querySelectorAll('[role="option"]')];
const currentId = listbox.getAttribute("aria-activedescendant");
const currentIndex = options.findIndex(o => o.id === currentId);
let nextIndex = currentIndex;
if (e.key === "ArrowDown") nextIndex = Math.min(currentIndex + 1, options.length - 1);
if (e.key === "ArrowUp") nextIndex = Math.max(currentIndex - 1, 0);
if (nextIndex !== currentIndex) {
options[currentIndex].removeAttribute("aria-selected");
options[nextIndex].setAttribute("aria-selected", "true");
listbox.setAttribute("aria-activedescendant", options[nextIndex].id);
// Screen reader announces: "Banana, selected, 2 of 3"
}
}
// 4. Skip links:
// <a href="#main-content" class="skip-link">Skip to main content</a>
// ...navigation...
// <main id="main-content" tabindex="-1">...</main>
8. Platform Accessibility API Mapping
// The accessibility tree is exposed to AT through platform-specific APIs:
// Windows:
// ├── MSAA (Microsoft Active Accessibility) — legacy
// ├── UIA (UI Automation) — modern, richer
// └── IAccessible2 (IA2) — Firefox, cross-platform extension of MSAA
// macOS:
// └── NSAccessibility protocol — Cocoa accessibility API
// Linux:
// └── ATK/AT-SPI (Accessibility Toolkit / AT Service Provider Interface)
// How a <button> maps across platforms:
//
// DOM: <button aria-label="Save">💾 Save</button>
//
// Chrome AX Tree:
// role: button
// name: "Save"
// focusable: true
//
// Windows UIA:
// ControlType: Button
// Name: "Save"
// IsKeyboardFocusable: true
// AutomationId: (from id attribute)
//
// macOS NSAccessibility:
// AXRole: AXButton
// AXTitle: "Save"
// AXEnabled: true
//
// Linux AT-SPI:
// Role: ROLE_PUSH_BUTTON
// Name: "Save"
// State: FOCUSABLE
// Screen reader event flow:
//
// 1. User presses Tab → focus moves to button
// 2. Browser updates accessibility tree focus
// 3. Platform API fires focus event:
// - Windows UIA: AutomationFocusChangedEvent
// - macOS: NSAccessibilityFocusedUIElementChangedNotification
// - Linux: focus: event on AT-SPI bus
// 4. Screen reader receives event
// 5. Screen reader queries element properties (role, name, state)
// 6. Screen reader synthesizes speech: "Save, button"
//
// When aria-expanded changes:
// 1. JS: button.setAttribute("aria-expanded", "true")
// 2. Browser updates AX tree property
// 3. Platform API fires property-changed event
// 4. Screen reader announces: "expanded"
// Chrome DevTools → Accessibility tab shows the computed AX tree
// Firefox DevTools → Accessibility Inspector
// These show EXACTLY what the screen reader sees
9. Common ARIA Patterns — Correct Implementation
<!-- 1. Disclosure (show/hide) -->
<button aria-expanded="false" aria-controls="content1">
Show Details
</button>
<div id="content1" hidden>
Details here...
</div>
<!-- 2. Tabs -->
<div role="tablist" aria-label="Settings">
<button role="tab" id="tab1" aria-selected="true"
aria-controls="panel1">General</button>
<button role="tab" id="tab2" aria-selected="false"
aria-controls="panel2" tabindex="-1">Advanced</button>
</div>
<div role="tabpanel" id="panel1" aria-labelledby="tab1">
General settings...
</div>
<div role="tabpanel" id="panel2" aria-labelledby="tab2" hidden>
Advanced settings...
</div>
<!-- Keyboard: Arrow keys move between tabs, Tab moves to panel -->
<!-- 3. Combobox (autocomplete) -->
<label for="search">Search</label>
<div role="combobox" aria-expanded="true" aria-haspopup="listbox">
<input id="search" type="text"
aria-autocomplete="list"
aria-controls="results"
aria-activedescendant="result-2">
</div>
<ul role="listbox" id="results">
<li role="option" id="result-1">Apple</li>
<li role="option" id="result-2" aria-selected="true">Banana</li>
<li role="option" id="result-3">Cherry</li>
</ul>
<!-- 4. Alert dialog -->
<div role="alertdialog" aria-modal="true"
aria-labelledby="dialog-title" aria-describedby="dialog-desc">
<h2 id="dialog-title">Delete Item?</h2>
<p id="dialog-desc">This action cannot be undone.</p>
<button>Cancel</button>
<button>Delete</button>
</div>
<!-- 5. Tree view -->
<ul role="tree" aria-label="File browser">
<li role="treeitem" aria-expanded="true">
src/
<ul role="group">
<li role="treeitem">index.ts</li>
<li role="treeitem" aria-expanded="false">
components/
<ul role="group">
<li role="treeitem">Button.tsx</li>
</ul>
</li>
</ul>
</li>
</ul>
<!-- 6. Data table with headers -->
<table aria-label="Quarterly Sales">
<thead>
<tr>
<th scope="col" aria-sort="ascending">Product</th>
<th scope="col" aria-sort="none">Q1</th>
<th scope="col" aria-sort="none">Q2</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Widget A</th>
<td>$1,200</td>
<td>$1,500</td>
</tr>
</tbody>
</table>
10. Testing and Debugging the Accessibility Tree
// Chrome DevTools — Accessibility pane:
// 1. Elements panel → Accessibility tab
// 2. Shows computed role, name, description, states
// 3. "Full accessibility tree" view in Elements panel
// Firefox Accessibility Inspector:
// 1. DevTools → Accessibility tab
// 2. Shows full accessibility tree with simulation options
// 3. Can simulate vision deficiencies
// Automated testing:
// axe-core (Deque) — the industry standard:
import axe from "axe-core";
axe.run(document.body, {
rules: {
"color-contrast": { enabled: true },
"label": { enabled: true },
},
}).then(results => {
console.log("Violations:", results.violations);
// Each violation includes:
// - id: "button-name"
// - impact: "critical"
// - description: "Buttons must have discernible text"
// - nodes: [affected elements]
// - help: link to fix
});
// Playwright accessibility testing:
import { test, expect } from "@playwright/test";
test("form is accessible", async ({ page }) => {
await page.goto("/form");
// Check for accessibility violations:
const snapshot = await page.accessibility.snapshot();
// Returns the accessibility tree as JSON:
// { role: "WebArea", name: "My Form", children: [...] }
// Verify specific elements:
const submitBtn = page.getByRole("button", { name: "Submit" });
await expect(submitBtn).toBeVisible();
await expect(submitBtn).toBeEnabled();
// Test keyboard navigation:
await page.keyboard.press("Tab");
const focused = await page.evaluate(() =>
document.activeElement?.getAttribute("aria-label") ||
document.activeElement?.textContent
);
expect(focused).toBe("First Name");
});
// Testing live regions (manual approach):
// 1. Open screen reader (VoiceOver: Cmd+F5, NVDA: free on Windows)
// 2. Perform action that triggers live region update
// 3. Verify announcement without moving focus
// getByRole — testing library's native a11y-first queries:
import { screen } from "@testing-library/react";
// These queries USE the accessibility tree:
screen.getByRole("button", { name: "Save" });
screen.getByRole("heading", { level: 2 });
screen.getByRole("checkbox", { checked: true });
screen.getByRole("tab", { selected: true });
screen.getByRole("alert");
screen.getByRole("navigation", { name: "Main" });
Trade-offs & Considerations
| Approach | Pros | Cons | When to Use |
|---|---|---|---|
Native HTML (<button>, <nav>) | Built-in keyboard + AT support, no ARIA needed | Limited to existing elements | Always — first choice |
| ARIA roles + states | Extends semantics for custom widgets | Must implement ALL keyboard behavior manually | Custom components with no native equivalent |
| aria-live regions | Announces dynamic changes without focus | Inconsistent timing across screen readers | Status updates, chat messages, errors |
| aria-activedescendant | Focus stays on container, simpler keyboard handling | Requires unique IDs for all options | Listbox, combobox, tree, grid |
| Focus management (programmatic) | Controls AT announcement on navigation | Easy to break — missing return focus, focus traps | SPA route changes, modals, dynamic content |
Best Practices
-
Use native HTML elements before ARIA — a
<button>gives you role, keyboard, and focus for free — ARIA's first rule is "don't use ARIA if you can use a native HTML element";<button>,<input>,<select>,<dialog>,<details>,<nav>,<main>provide correct roles, keyboard handling, and states without any additional attributes. -
Every interactive element must have an accessible name — run the AccName algorithm mentally: does it have
aria-labelledby?aria-label? A<label>? Visible text content? If the answer is no, screen readers announce "button" with no context; usearia-labelas the simplest fix for icon-only controls. -
Make aria-live regions exist in the DOM before content changes — insert the container with
aria-live="polite"(or userole="status") in the initial HTML, then update itstextContentto trigger announcements; dynamically creating the live region and its content simultaneously may not be announced. -
Return focus to the trigger element when closing modals, dropdowns, and popovers — if focus is inside a dialog and the dialog closes without moving focus back, the screen reader user is lost with focus reset to
<body>; always store the trigger reference and calltriggerElement.focus()on close. -
Test with a real screen reader, not just automated tools — axe-core catches ~30-40% of accessibility issues (missing labels, roles, contrast); it cannot detect broken keyboard flows, confusing announcement order, missing live region timing, or illogical focus management; test with VoiceOver (macOS), NVDA (Windows), and TalkBack (Android).
Conclusion
The accessibility tree is a parallel structure the browser constructs from the DOM, exposing semantic information — roles, names, states, and relationships — to assistive technology through platform APIs (UIA on Windows, NSAccessibility on macOS, AT-SPI on Linux). Role computation follows a precedence: explicit role attribute overrides implicit HTML semantics, with native elements like <button> (role: button), <nav> (role: navigation), and <input type="checkbox"> (role: checkbox) providing correct roles automatically. The accessible name is computed by a strict algorithm: aria-labelledby beats aria-label beats native labeling (<label>, alt) beats text content beats title. ARIA states — aria-expanded, aria-selected, aria-checked, aria-activedescendant — reflect dynamic UI state that screen readers announce on change. Live regions (aria-live="polite", role="alert", role="status") announce content changes without requiring focus to move. Focus management in SPAs requires explicit work: moving focus to new page headings on route changes, trapping focus in modals, and returning focus to trigger elements on close. The first rule remains: use native HTML elements — they provide correct semantics, keyboard support, and platform API mapping for free. ARIA is the extension mechanism for custom widgets that have no native HTML equivalent, but it comes with the responsibility of implementing all keyboard and state management manually.
What did you think?