Siddhant Deval
Siddhant Deval
frontend5 min read

Semantic HTML & The Accessibility Tree: How Browsers Transform DOM into AXTree Nodes

The browser compiles two distinct trees from your markup: the visual DOM and the Accessibility Tree (AXTree). This article demystifies the browser's C++ compilation pipeline, AccName 1.2 name calculation, hydration-safe ID generation with React 18/19 useId(), and the architectural mechanics of the native inert attribute.

Semantic HTML & the Browser Accessibility Tree: How the AXTree Is Compiled

Part 1 established the multi-modal user spectrum, WCAG POUR as engineering invariants, and the Portal Shell landmark skeleton. But understanding why certain HTML elements and ARIA attributes produce correct screen reader output requires going one level deeper — inside the browser's accessibility compilation pipeline. Most engineers treat accessibility semantics as a surface annotation layer. They are not. They are the output of a deterministic compilation process that runs in parallel with the CSS cascade, has its own data model, its own name-calculation algorithm, and its own tree pruning logic. Misunderstanding this pipeline is the root cause of most ARIA bugs.

This article opens the browser's accessibility engine and traces the Portal Canvas's navigation bar and settings form through each compilation stage, from HTML tokens to the resolved Accessibility Tree entries that VoiceOver, NVDA, and JAWS actually receive.


1. The Parallel Compilation Pipeline

The browser maintains two parallel trees derived from the same HTML source: the DOM tree (manipulated by JavaScript) and the Accessibility Tree (consumed by platform AT APIs). They are not the same data structure.

1.1 DOM → AXTree Compilation Stages

The Accessibility Tree Builder performs three operations on every DOM node:

  1. Role mapping — derives an AX role from the element's tag, type attribute, and any ARIA role override
  2. Name computation — runs the AccName 1.2 algorithm to calculate the element's accessible name
  3. State resolution — maps DOM properties and ARIA attributes to AX state flags (focused, checked, expanded, disabled, etc.)

1.2 Role Mapping — Implicit vs. Explicit

Every HTML element carries an implicit ARIA role derived from the HTML specification. These implicit roles are registered in the ARIA in HTML spec.

Element Implicit AX Role Notes
<button> button Keyboard activatable via Enter + Space
<a href="..."> link Tab-focusable; Enter activates
<input type="text"> textbox —
<input type="checkbox"> checkbox Supports checked, indeterminate states
<input type="radio"> radio —
<select> listbox —
<img alt="..."> img Non-empty alt required for role to be exposed
<img alt=""> none (pruned) Empty alt removes from AX tree entirely
<nav> navigation Landmark; appears in AT rotor
<main> main Landmark; exactly one per page
<header> (top-level) banner Only when not inside <article> or <section>
<footer> (top-level) contentinfo Only when not inside <article> or <section>
<div>, <span> none (generic) No AX role unless ARIA role added
Crucial Requirement

<div> and <span> have no implicit ARIA role. Adding role="button" to a <div> gives it an AX role but does NOT give it any of the native behaviors that come with <button>: keyboard focusability (requires tabindex="0"), Enter/Space key activation (requires manual onKeyDown handler), and browser-level form participation. Each of these must be manually implemented. This is why native HTML elements are almost always preferable.


2. The AccName 1.2 Algorithm — How Accessible Names Are Computed

The accessible name is the string that screen readers announce when a user focuses or navigates to an element. It is not simply the text content of the element. The browser resolves accessible names through a strict waterfall specified in Accessible Name and Description Computation 1.2.

2.1 The AccName Computation Waterfall

2.2 AccName in Practice — Portal Navigation

Applying the waterfall to the Portal Canvas navigation:

TYPESCRIPT
// ❌ Broken — two <nav> elements with identical role "navigation" and no distinguishing name
// NVDA announces "navigation" for both; VoiceOver lists two items both called "navigation"
function Layout() {
  return (
    <>
      <nav>
        <a href="/dashboard">Dashboard</a>
      </nav>
      <nav>
        <a href="/profile">Profile</a>
      </nav>
    </>
  );
}

// ✅ aria-label differentiates landmark regions in the AT rotor
function Layout() {
  return (
    <>
      <nav aria-label="Global navigation">
        <a href="/dashboard">Dashboard</a>
      </nav>
      <nav aria-label="User account">
        <a href="/profile">Profile</a>
      </nav>
    </>
  );
}
// VoiceOver Landmarks Rotor now shows:
// "navigation, Global navigation"
// "navigation, User account"
TYPESCRIPT
// ❌ Icon button — AX name is empty; screen reader announces "button"
<button>
  <svg aria-hidden="false">...</svg>
</button>

// ❌ Icon button with tooltip — tooltip is not the AX name unless linked
<button title="Close">
  <svg>...</svg>
</button>
// title is used as fallback name by AccName — BUT tooltips are invisible to
// keyboard users on many platforms. title alone is not sufficient.

// ✅ Correct — aria-label provides name; svg hidden from AX tree
<button type="button" aria-label="Close dialog">
  <svg aria-hidden="true" focusable="false">...</svg>
</button>

// ✅ Alternative — visually hidden text is flat-traversed by AccName
<button type="button">
  <svg aria-hidden="true" focusable="false">...</svg>
  <span className="sr-only">Close dialog</span>
</button>
Architectural Note

focusable="false" on SVG is a legacy IE fix that is still necessary for some older browser+AT combinations. In modern Chrome + NVDA, SVG elements are not focusable by default, but adding focusable="false" is a safe defensive practice.

2.3 AccName for Form Inputs — The <label> Algorithm

<label> elements are resolved before aria-labelledby in the form control name computation (they share the same result slot):

TYPESCRIPT
// ❌ Visual label proximity does not create programmatic association
<p>First name</p>
<input type="text" />
// Screen reader announces: "Edit text, blank" — no association

// ❌ Wrapping without htmlFor — works for mouse click but is unreliable in some AT
// (forLabel + input must be within the same label element)
<label>
  First name
  <input type="text" />
</label>
// Implicit association — works in most AT but explicit is preferred

// ✅ Explicit association — gold standard; works across all AT
<label htmlFor="first-name">First name</label>
<input id="first-name" type="text" />
// Screen reader announces: "First name, edit text"

// ✅ react — useId prevents SSR hydration mismatches from duplicate IDs
function FirstNameField() {
  const id = useId(); // generates deterministic :r0: style ID
  return (
    <div>
      <label htmlFor={id}>First name</label>
      <input id={id} type="text" autoComplete="given-name" />
    </div>
  );
}
Performance / Safety Warning

Generating IDs with Math.random() or sequential counters in React causes SSR hydration mismatches: the server renders id="field-1" but the client renders id="field-3" (because other components mounted first in a different order). Use useId() (React 18+) which generates stable, SSR-consistent IDs. This is the only correct solution for accessible ID references in React applications.


3. CSS Hiding — AXTree Side Effects

The choice of CSS hiding technique determines whether an element is removed from the AXTree, preserved in it, or partially preserved. This distinction is critical for implementing visually-hidden utility classes (.sr-only) and for hiding decorative content from AT.

3.1 CSS Hiding Technique Comparison

Technique Visible? In AXTree? Focusable? Use Case
display: none ❌ ❌ ❌ Hidden from all users; use for closed modals, collapsed accordions
visibility: hidden ❌ ❌ ❌ Hidden from all; children can override with visibility: visible
opacity: 0 ❌ ✅ ✅ Visible to AT; focusable — can cause invisible focus traps
clip-path: inset(100%) (sr-only) ❌ ✅ ✅ Visually hidden but AT-exposed — correct for .sr-only
content-visibility: hidden ❌ ❌ ❌ Performance optimization; removes from AXTree (Chrome 85+)
aria-hidden="true" ✅ ❌ ❌ Visual only; removes from AT — use for decorative icons, duplicated content
TYPESCRIPT
// ✅ The correct sr-only utility class — visually hidden, AT-visible, focusable
// (follows Bootstrap / Tailwind CSS pattern, verified across all AT)
const srOnlyStyles: CSSProperties = {
  position: 'absolute',
  width: '1px',
  height: '1px',
  padding: '0',
  margin: '-1px',
  overflow: 'hidden',
  clip: 'rect(0,0,0,0)',
  whiteSpace: 'nowrap',
  borderWidth: '0',
};

[!CAUTION] opacity: 0 keeps elements in the AXTree and focusable. If a component uses opacity: 0 to "hide" a panel but does not also add aria-hidden="true" or display: none, screen reader users can navigate into the invisible content and get lost. This is one of the most common invisible-focus-trap bugs in production accessibility.

3.2 aria-hidden Anti-Patterns

TYPESCRIPT
// ❌ CRITICAL BUG — aria-hidden on an element that contains focused children
// A screen reader user can tab into the hidden region; AT announces nothing.
// Focus disappears silently — the most disorienting AT experience possible.
function Layout() {
  const isOpen = false;
  return (
    <div aria-hidden={!isOpen}>
      <button>Submit</button>  {/* Keyboard-focusable but AT-invisible */}
    </div>
  );
}

// ✅ Correct: remove interactive elements from tab order when aria-hidden
function Layout() {
  const isOpen = false;
  return (
    <div aria-hidden={!isOpen} inert={!isOpen}>
      {/* inert removes both from tab order AND from AXTree */}
      <button>Submit</button>
    </div>
  );
}

4. The Portal Canvas Navigation — Full AXTree Trace

Tracing the Portal Canvas global navigation through the full AXTree compilation:

HTML
<nav aria-label="Global navigation">
  <ul>
    <li><a href="/dashboard" aria-current="page">Dashboard</a></li>
    <li><a href="/workspace">Workspace</a></li>
    <li><a href="/analytics">Analytics</a></li>
  </ul>
</nav>

Computed AXTree nodes:

VoiceOver's announcement when focusing the first link: "Dashboard, current page, link, in Global navigation, navigation landmark."

The aria-current="page" attribute signals to the AT that this link is the current location — it does not change navigation behavior, only the announcement. Without it, VoiceOver announces: "Dashboard, link" — no indication that this is the active page.

TYPESCRIPT
// ✅ Active nav link with aria-current in React
interface NavLinkProps {
  href: string;
  children: ReactNode;
  currentPath: string;
}

function NavLink({ href, children, currentPath }: NavLinkProps) {
  const isCurrent = href === currentPath;
  return (
    <a
      href={href}
      aria-current={isCurrent ? 'page' : undefined}
      className={isCurrent ? 'nav-link active' : 'nav-link'}
    >
      {children}
    </a>
  );
}

5. The useId() Hook — SSR-Safe Accessible References

React 18's useId() solves a fundamental accessibility problem in server-rendered React applications: stable, unique IDs for htmlFor/id pairs, aria-labelledby/aria-describedby references, and aria-controls associations.

TYPESCRIPT
// ❌ Counter-based IDs — hydration mismatch when SSR mount order differs from client
let counter = 0;
function useAccessibleId() {
  return useRef(`field-${++counter}`).current; // Different values server vs client
}

// ❌ Math.random() — completely different every render (also SSR-broken)
function Field() {
  const id = useState(() => `field-${Math.random()}`)[0];
  // Server: "field-0.4892..."  Client: "field-0.8123..." → hydration mismatch
}

// ✅ React 18 useId() — deterministic, SSR-safe, collision-free
function DescribedField({ helpText }: { helpText: string }) {
  const inputId = useId();
  const descId = useId();
  return (
    <div>
      <label htmlFor={inputId}>Workspace name</label>
      <input
        id={inputId}
        type="text"
        aria-describedby={descId}
      />
      <p id={descId} className="field-hint">
        {helpText}
      </p>
    </div>
  );
}
Pro Tip & Optimization

useId() generates IDs in the format :r0:, :r1:, etc. — these are valid HTML IDs (they contain no spaces). The leading : is intentional and makes them globally unique within the React tree. Do not try to parse or format these values; treat them as opaque strings.


Summary

Concept Rule
The Accessibility Tree is a parallel compiled data structure; its correctness depends on HTML semantics, ARIA attributes, and CSS hiding choices — not just visual layout. Reason about AXTree outputs when writing HTML, not just visual outputs.
The AccName waterfall resolves names in strict priority: aria-labelledby > aria-label > native naming (alt, title, value) > text content. Every interactive element must have a resolvable name at the top of the waterfall.
CSS hiding techniques have different AXTree implications; opacity: 0 leaves elements in the AXTree and focusable, creating invisible focus traps. Use display: none or inert to fully remove content from both AXTree and tab order.
useId() generates SSR-stable IDs for accessible htmlFor, aria-labelledby, and aria-describedby relationships in React applications. Always use useId() for accessible ID references in React — never counters or Math.random().

What's Next

Part 3 builds the keyboard focus state machine: how to model focus as an explicit finite-state automaton using useReducer, the inert attribute, and focus-visible CSS to eliminate invisible focus traps, implement roving tabindex for composite widgets, and architect a Focus Guard that enforces focus restoration when dialogs close.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#Accessibility#a11y#AXTree#Browser Internals#AccName#React 19#HTML5 inert
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.