Recursive AI

What is the accessibility tree?

The accessibility tree is what browsers expose to screen readers: roles, names and states built from the DOM. See how it differs and compute it in C#.

The accessibility tree is the structure a browser builds from the DOM and hands to assistive technology such as screen readers. Each node has a role (button, heading, link), an accessible name, sometimes a description, and states such as checked, expanded or required. Elements that carry no meaning are dropped or ignored, and hidden content is removed. Chrome’s developer blog describes it as “a tree of accessibility objects that assistive technology can query for attributes and properties and perform actions on” (Chrome for Developers). MDN lists the same four properties: name, description, role and state (MDN). A screen-reader user never sees your markup; they meet this tree. This page shows how the tree differs from the DOM on a real example, how to inspect it in Chrome and Playwright, and how to compute it in C# without a browser, using onehtml 1.0.0 on .NET 10.

How the accessibility tree is built

The browser parses the HTML into the DOM, computes styles, and then derives the accessibility tree from both. Three specifications define most of the result:

  • Roles come from the element (HTML Accessibility API Mappings: a button is a button and a nav is navigation), unless a role attribute overrides it (WAI-ARIA 1.2).
  • Names follow the Accessible Name and Description Computation 1.2: aria-labelledby, then aria-label, then native sources such as a label element or alt, then the element’s own text for roles that take their name from content, then title and placeholders.
  • Hidden content is decided by CSS and attributes: display: none, the hidden attribute and aria-hidden="true" take content out of what assistive technology gets. CSS that only makes something small or clipped doesn’t.

The specifications leave some choices to browsers, so Chrome, Firefox and Safari can expose slightly different trees for the same page. Platform accessibility APIs differ again: Windows, macOS and Linux each have their own names for roles and properties.

DOM vs accessibility tree: an example

This checkout page has the usual mix: a labeled field, a checkbox inside its label, a decorative image, a div styled as a button, an icon button with visually hidden text, and a promo banner switched off with display: none.

checkout.htmlHTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Payment · Checkout</title>
<style>
  .visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
  .promo { display: none; }
</style>
</head>
<body>
<nav aria-label="Checkout steps">
  <ol>
    <li><a href="/cart">Cart</a></li>
    <li><a href="/pay" aria-current="step">Payment</a></li>
  </ol>
</nav>
<main>
  <h1>Payment</h1>
  <p class="promo">Free shipping on orders over $50</p>
  <label for="card">Card number</label>
  <input id="card" autocomplete="cc-number" required>
  <label><input type="checkbox" checked> Save this card</label>
  <img src="lock.svg" alt="">
  <div class="btn" onclick="pay()">Pay now</div>
  <button type="button"><svg aria-hidden="true" width="16" height="16"></svg><span class="visually-hidden">Close</span></button>
</main>
</body>
</html>

onehtml builds the tree from the parsed document and its cascade:

TerminalNuGet
dotnet new console -o AxTree
cd AxTree
dotnet add package RecursiveAI.OneHtml.Accessibility --version 1.0.0
Program.csonehtml 1.0.0 · .NET 10
using RecursiveAI.OneHtml;
using RecursiveAI.OneHtml.Accessibility;
using RecursiveAI.OneHtml.Css;
 
Document doc = Document.Parse(File.ReadAllBytes("checkout.html"));
AccessibilityTree tree = AccessibilityTree.Build(doc, DocumentStyles.Create(doc));
 
// Print the tree as an outline: role, accessible name and a few states
Print(tree.Root, 0);
 
// Why each node looks the way it does
foreach (string selector in new[] { "#card", "button", "div.btn", "img", "p.promo" })
{
    Node element = doc.Root.QuerySelector(selector);
    AxNode? node = tree.Find(element);
    AxText name = tree.ComputeName(element);
    string role = node is null ? "(not in the tree)" : node.IsIgnored ? "(ignored)" : node.Role.ToString();
    Console.WriteLine($"{selector,-8} role {role}, name \"{name.Value}\" from {name.Source}, hidden: {tree.GetHidden(element).Reasons}");
}
 
void Print(AxNode node, int depth)
{
    if (!node.IsIgnored)
    {
        string name = node.Name.IsEmpty ? "" : $" \"{node.Name.Value}\"";
        string states = string.Concat(
            node.States.Level is int level ? $" [level={level}]" : "",
            node.States.Required ? " [required]" : "",
            node.States.Checked is { } c ? $" [checked={c}]" : "",
            node.Focus.IsFocusable ? " (focusable)" : "");
        string role = node.Role.Length > 0 ? node.Role : node.InternalRole ?? "";   // Chrome's internal roles, such as LabelText
        Console.WriteLine($"{new string(' ', depth * 2)}{role}{name}{states}");
        depth++;
    }
    foreach (AxNode child in node.Children)
    {
        Print(child, depth);
    }
}
OutputConsole
RootWebArea "Payment · Checkout"
  navigation "Checkout steps"
    list
      listitem [level=1]
        link "Cart" (focusable)
          StaticText "Cart"
      listitem [level=1]
        link "Payment" (focusable)
          StaticText "Payment"
  main
    heading "Payment" [level=1]
      StaticText "Payment"
    LabelText
      StaticText "Card number"
    textbox "Card number" [required] (focusable)
    checkbox "Save this card" [checked=True] (focusable)
    generic
      StaticText "Pay now"
    button "Close" (focusable)
      generic
        StaticText "Close"
#card    role textbox, name "Card number" from NativeLabelElement, hidden: None
button   role button, name "Close" from Contents, hidden: None
div.btn  role generic, name "" from None, hidden: None
img      role (ignored), name "" from None, hidden: None
p.promo  role (not in the tree), name "" from None, hidden: DisplayNone

What changed between the DOM and the accessibility tree:

  • The div with an onclick is not a button. It has the role generic, no name and no focus, so a screen reader announces “Pay now” as plain text and keyboard users can’t reach it with Tab. A <button> gets all three for free. This is the most common way markup and the accessibility tree disagree.
  • The icon button is named “Close” from its contents. The svg is aria-hidden, so it contributes nothing, and the visually hidden span still counts: clipping it to one pixel hides it from sight, not from the tree.
  • The promo paragraph is gone. display: none removes it and its subtree, and GetHidden reports the reason, DisplayNone.
  • The image with alt="" is ignored, which is correct for a decorative image. Without the alt attribute it would be exposed as an image with no name, and the image-alt check would fail.
  • The text field takes its name from the label element, and required becomes a state. The checkbox is named by the label that wraps it.
  • Wrapper elements disappear. html and body are ignored, and the document itself becomes RootWebArea, named by the page’s title.

Names are traced: ComputeName returns where each one came from (NativeLabelElement, Contents and so on) and which elements it was read from. When a name is wrong, that’s usually all you need to know to fix it.

How to see the accessibility tree in Chrome

In Chrome DevTools, select an element in the Elements panel and open the Accessibility pane to see its role, name, the source of the name, and its ancestors. The pane also has a switch for the full-page accessibility tree, which replaces the DOM tree view (Chrome for Developers). By default it hides ignored nodes and unnamed generic nodes to reduce clutter. Microsoft Edge has the same tools (Microsoft Learn).

To read Chrome’s tree from code, the Chrome DevTools Protocol method Accessibility.getFullAXTree returns every node. Here it is from C# through Playwright, compared with onehtml’s ToChromeJson(), which writes the same tree in that protocol’s node format:

TerminalNuGet
dotnet add package Microsoft.Playwright --version 1.64.1
dotnet build
pwsh bin/Debug/net10.0/playwright.ps1 install chromium
Program.csonehtml 1.0.0 · Chromium 156
using System.Text.Json;
using Microsoft.Playwright;
using RecursiveAI.OneHtml;
using RecursiveAI.OneHtml.Accessibility;
using RecursiveAI.OneHtml.Css;
 
string html = await File.ReadAllTextAsync("checkout.html");
 
// Chrome's tree, from the DevTools Protocol
using IPlaywright playwright = await Playwright.CreateAsync();
await using IBrowser browser = await playwright.Chromium.LaunchAsync();
IPage page = await browser.NewPageAsync();
await page.SetContentAsync(html);
ICDPSession cdp = await page.Context.NewCDPSessionAsync(page);
JsonElement chrome = (await cdp.SendAsync("Accessibility.getFullAXTree"))!.Value;
 
// onehtml's tree, exported in the same AXNode shape, with no browser
Document doc = Document.Parse(html);
AccessibilityTree tree = AccessibilityTree.Build(doc, DocumentStyles.Create(doc));
JsonElement ours = JsonDocument.Parse(tree.ToChromeJson()).RootElement;
 
List<string> chromeNodes = Exposed(chrome.GetProperty("nodes"));
List<string> ourNodes = Exposed(ours.GetProperty("nodes"));
Console.WriteLine($"Chrome: {chromeNodes.Count} nodes, onehtml: {ourNodes.Count} nodes, same roles and names: {chromeNodes.SequenceEqual(ourNodes)}");
foreach (string node in ourNodes.Where(n => !n.StartsWith("StaticText")))
{
    Console.WriteLine($"  {node}");
}
 
// Role and name of every node that isn't ignored, sorted. Nodes that only exist
// once text is laid out (line boxes, list markers, drawn spaces) are left out.
static List<string> Exposed(JsonElement nodes) => nodes.EnumerateArray()
    .Where(n => !n.GetProperty("ignored").GetBoolean())
    .Select(n => (Role: n.GetProperty("role").GetProperty("value").GetString()!,
                  Name: n.TryGetProperty("name", out JsonElement name) ? name.GetProperty("value").GetString()! : ""))
    .Where(n => n.Role is not ("InlineTextBox" or "ListMarker") && !(n.Role == "StaticText" && string.IsNullOrWhiteSpace(n.Name)))
    .Select(n => $"{n.Role} \"{n.Name}\"")
    .Order(StringComparer.Ordinal)
    .ToList();
OutputConsole
Chrome: 21 nodes, onehtml: 21 nodes, same roles and names: True
  LabelText ""
  RootWebArea "Payment · Checkout"
  button "Close"
  checkbox "Save this card"
  generic ""
  generic ""
  heading "Payment"
  link "Cart"
  link "Payment"
  list ""
  listitem ""
  listitem ""
  main ""
  navigation "Checkout steps"
  textbox "Card number"

On this page, every node Chrome 156 exposes has the same role and name in onehtml’s tree. The comparison leaves out three kinds of node that Chrome creates from layout: InlineTextBox (one per line of text), ListMarker (the “1.” of a list item) and spaces that were drawn between inline elements. onehtml doesn’t lay out the page, so it doesn’t create these unless you supply Chrome’s layout as rendered facts (Chromium profile). Across a crawled corpus, the measured differences from Chrome, field by field, are published in the Chrome accessibility-tree differential.

The accessibility tree in Playwright

Playwright’s aria snapshots give a YAML view of the accessibility tree for snapshot tests (Playwright docs):

Program.csPlaywright 1.64.1
using Microsoft.Playwright;
 
using IPlaywright playwright = await Playwright.CreateAsync();
await using IBrowser browser = await playwright.Chromium.LaunchAsync();
IPage page = await browser.NewPageAsync();
await page.SetContentAsync(await File.ReadAllTextAsync("checkout.html"));
 
// Chromium's accessibility tree, as Playwright's YAML aria snapshot
Console.WriteLine(await page.Locator("body").AriaSnapshotAsync());
OutputConsole
- navigation "Checkout steps":
  - list:
    - listitem:
      - link "Cart":
        - /url: /cart
    - listitem:
      - link "Payment":
        - /url: /pay
- main:
  - heading "Payment" [level=1]
  - text: Card number
  - textbox "Card number"
  - checkbox "Save this card" [checked]
  - text: Save this card Pay now
  - button "Close"

The snapshot is written for assertions, so it is condensed: it starts at the locator you pass, links carry their URL, the generic div doesn’t appear, and neighboring text merges into one text: line. On this page it also lists “Save this card” as text, while Chrome’s own tree from getFullAXTree marks that text node as ignored. Use aria snapshots to pin a page’s structure in an end-to-end test, and the protocol tree or onehtml when you need roles, names and states as data.

Why the accessibility tree matters for testing

Most automated accessibility checks are questions about this tree. Does every image have a name? Does every button? Do form fields have labels? Is this div with a click handler reachable by keyboard? With the tree as data, you can answer them in a unit test:

  • CreateNavigationLists() returns the landmarks, headings, links, form fields and tables a screen-reader user jumps between, which makes heading-structure tests short.
  • CreateReadingView() is the page as a sequence of announcements, the order a screen reader reads it in (see extracting visible text).
  • tree.Diff(otherTree) compares two trees, for example before and after a change, or with and without a focused element.
  • The WCAG checker runs its rules over the same tree. Automated accessibility testing in .NET shows it in xUnit, ASP.NET Core integration tests and Selenium.

onehtml’s accessible name computation is tested against the Web Platform Tests: 1,297 accessible-name cases pass, with 29 known failures, each listed with its reason. For roles, 536 pass with 18 known failures, all in tentative test pages (conformance table). Without a browser, scripts don’t run, and hiding that depends on layout (zero size, off-screen positioning) isn’t known. A Chrome DOM snapshot passed as rendered facts adds what layout decides (rendered facts).

Where next