Accessibility · Semantics and the A11y Tree

How a Document Becomes an Accessibility Tree

Which DOM nodes a browser exposes to assistive technology, which it discards, and why ignored containers are flattened instead of deleted.

The document object model records what the author wrote. The accessibility tree records what an assistive technology can address: a smaller structure of objects, each carrying a role, a name, a set of states, and sometimes text. Screen readers, switch access, voice control, and OS-level automation all consume that second structure, never the DOM itself. The distance between the two is where most surprising accessibility behaviour lives.

The tree is not a separate document that authors maintain. It is computed, incrementally, from three inputs: the DOM, the computed style, and layout. A node that has no boxes in layout cannot have a bounding rectangle, and an object without geometry cannot be hit-tested or drawn as a focus indicator, so it does not appear. display: none and visibility: hidden remove subtrees for that reason. opacity: 0, off-screen positioning, clipping, and a zero size do not remove anything: the element still has a layout box, so it keeps its object and stays reachable by a screen reader. Authors who try to hide content with opacity and height: 0 are hiding it from sighted users only.

The first pass assigns every surviving element an implicit role from its tag and attributes. A button becomes a push button, an h2 becomes a heading with level 2, a input type="checkbox" becomes a check box, an a with an href becomes a link. This mapping is specified in HTML-AAM, which gives each element a role, the states it implies, and the attributes that override the defaults. An element with no host-language semantics and no explicit role still gets an object in some browsers, but the object is generic: it carries no role that a screen reader would announce, and it usually has no name of its own.

Ignored is not deleted

A page written with <div> wrappers produces a DOM deeper than the semantics warrant. Three nested divs around a paragraph describe grid placement, not structure. Exposing each one would force a screen reader to traverse three unlabelled groups to reach one sentence, so browsers mark such nodes ignored and splice their children into the nearest unignored ancestor. The platform tree never sees the wrapper; the internal tree keeps it.

Keeping the ignored node internally matters because the mapping has to survive change. When the wrapper later gains tabindex, or an aria-label, or a child that requires a different parent, the internal object already exists and only its ignored flag flips. A design that deleted the node would have to rebuild the subtree and re-establish the parent-child links that assistive technologies hold by reference.

The rules for ignoring are narrower than “a div with nothing on it”. An element is not ignored if it is focusable, if it carries a global ARIA attribute, if it is the target of an aria-labelledby or aria-describedby reference, or if it has a role that requires children. A generic div with tabindex="0" therefore becomes a focusable generic object, which is worse than leaving it alone: it appears in the tab order but announces nothing. The mechanics of that exception, and of the presentational roles that request ignoring explicitly, are covered in Hidden Subtrees and Presentational Roles.

Text is aggregated rather than mapped one node per character. Consecutive text nodes inside one block container collapse into a single static text object, and the object stores the concatenated string plus the offsets needed to answer range queries. Inline elements that do not establish semantics (span, b, i without a role) are folded into the run, so <p>Hello <span>world</span></p> yields one text object containing “Hello world”. A <br> splits the run, and each block-level child starts a new one. This aggregation is why a paragraph written as one character per span is read as a stream of one-letter words: the split happens at the inline boundary only when the inline element is itself exposed.

<div class="card">           <!-- generic, ignored; children re-parented -->
  <div class="card__body">   <!-- generic, ignored -->
    <h2>Retry policy</h2>    <!-- heading level 2 -->
    <p>Backoff is capped at 30 s.</p>
  </div>
</div>

In the fragment above the platform tree contains a heading and a paragraph as siblings. The two wrappers exist in the DOM and in layout, but not in the structure delivered to the screen reader. A CSS selector that depends on the wrapper still works; a query for the heading’s parent returns the ignored node internally and the document root externally.

Building on demand

Maintaining a parallel tree for every tab would cost memory and CPU that most sessions never use. Browsers therefore build lazily. Chromium computes accessibility objects when a client asks for them through the platform API, or when a feature that needs them is active; until then the DOM and layout are the only structures. Firefox and WebKit use similar conditions with different triggers. Once a client attaches, the browser constructs an initial snapshot of the whole document and then updates it incrementally, once per rendering update, before it dispatches the corresponding platform events.

The lazy path has a cost that is easy to miss. A client that attaches after load receives a tree reflecting the current state, which is correct; a client that attaches while an animation or a scripted insertion is in flight may receive the state at the end of the next frame instead of at the moment of the mutation. Assistive technologies compensate by re-reading the subtree around a focus change rather than trusting that every intermediate state produced an event. The event pipeline itself is described in Accessibility Tree Invalidation and Event Coalescing.

A single document with ten thousand elements can produce an accessibility tree of comparable size; each object carries a role, a name buffer, state flags, and a rectangle, so the memory is measured in hundreds of kilobytes rather than megabytes, but the update cost grows with the number of dirty objects, not with the number of visible ones. Hiding a large off-screen list with display: none removes its objects entirely and is the only reliable way to keep that subtree out of the update cycle.

Roles are computed before names

The second pass computes each object’s name, and the order matters: a node’s name depends on its role, because only certain roles take their name from their contents. A heading takes its name from the text inside it; a text field does not, and must be labelled from outside. This is why an <h2> needs no aria-label and an <input> does. The ladder of name sources, and the point at which each source is consulted, is laid out in The Order of Sources in Accessible Name Computation.

Role resolution has its own precedence, and an explicit role attribute does not always beat the element. Some elements have strong native semantics that a role token may replace only for a compatible set of values, and some role tokens are abstract and therefore invalid. The token list is scanned left to right and the first recognised, non-abstract role wins. That rule is what makes fallback chains such as role="switch checkbox" work in browsers that do not know switch: the unknown token is skipped, the known one applies. The details, including the elements where an explicit role is rejected outright, are in Implicit Roles and How Role Conflicts Resolve.

Where the mapping leaks

Four failures account for most mismatches between what the DOM says and what the tree exposes.

display: contents removes the element’s own box while keeping its children in layout. The element therefore has no geometry, so it loses its object, and any role, name, or state attached to it disappears with it. A wrapper carrying role="group" and an aria-label becomes a transparent nothing.

aria-hidden="true" removes an object from the tree but does not remove it from the tab order. An element that is both hidden this way and focusable produces a focus stop with no accessible object. Browsers expose an ignored node with focus inside it to keep focus tracking coherent, which is exactly the inconsistency the author was trying to avoid.

Generated content from ::before and ::after may appear in the tree as text inside the element’s own object or as a separate static text object, depending on the engine and on whether the pseudo-element is treated as a child. Content injected for decoration is therefore sometimes announced, and content injected to carry meaning is sometimes dropped. Neither outcome is stable across engines.

Finally, an element whose implicit role is generic (a bare div or a bare span) cannot take a name from the author. aria-label on such an element is prohibited by the specification, and implementations that once exposed it have moved toward ignoring it. The rule is not arbitrary: a name on an object with no role gives the screen reader nothing to announce it as, so the name has no slot to occupy.

References

  1. Core Accessibility API Mappings 1.2 — W3C
  2. HTML Accessibility API Mappings 1.2 — W3C
  3. HTML Standard — WHATWG
  4. Accessibility Overview — Chromium project documentation