Recursive AI

The accessibility tree of a page

All onehtml recipes

Compute the tree a browser exposes to screen readers, with roles, accessible names, levels and hidden state, from HTML and CSS alone, and get the landmark and heading lists a screen-reader user navigates by.

AccessibilityTree.Build takes a document and its cascade. It applies WAI-ARIA 1.2, HTML-AAM, AccName 1.2, Core-AAM and SVG-AAM, and uses Chromium’s choices where the specifications leave room. Each of those choices is listed in Chromium profile.

C#
using RecursiveAI.OneHtml;
using RecursiveAI.OneHtml.Css;
using RecursiveAI.OneHtml.Accessibility;
 
Document doc = Document.Parse(File.ReadAllBytes("events.html"));
AccessibilityTree tree = AccessibilityTree.Build(doc, DocumentStyles.Create(doc));
 
// The tree as an outline: role, name, and the states that are set
void Print(AxNode node, int depth)
{
    if (!node.IsIgnored)
    {
        string name = node.Name.IsEmpty ? "" : $" \"{node.Name.Value}\"";
        string level = node.States.Level is int l ? $" level={l}" : "";
        Console.WriteLine($"{new string(' ', depth * 2)}{node.Role}{name}{level}");
        depth++;
    }
    foreach (AxNode child in node.Children)
    {
        Print(child, depth);
    }
}
Print(tree.Root, 0);
 
// Why an element is named the way it is, and why one is missing from the tree
Node logo = doc.Root.QuerySelector("a.logo");
AxText logoName = tree.ComputeName(logo);
Console.WriteLine($"Logo link name \"{logoName.Value}\" comes from {logoName.Source}");
 
Node icon = doc.Root.QuerySelector("button svg");
AxNode? iconNode = tree.Find(icon);
Console.WriteLine($"Icon ignored: {iconNode?.IsIgnored}, hidden: {tree.GetHidden(icon).Reasons}");
 
// The lists a screen reader offers for jumping around
NavigationLists lists = tree.CreateNavigationLists();
foreach (LandmarkEntry landmark in lists.Landmarks)
{
    Console.WriteLine($"{new string(' ', landmark.Depth * 2)}{landmark.Role} {landmark.Name}".TrimEnd());
}
foreach (HeadingEntry heading in lists.Headings)
{
    Console.WriteLine($"{new string(' ', (heading.Level - 1) * 2)}h{heading.Level} {heading.Name}");
}
 
// The same tree in Chrome's AX-tree JSON shape
File.WriteAllText("events.ax.json", tree.ToChromeJson());
Text
RootWebArea "Events | Harbor Street Library"
  banner
    link "Harbor Street Library"
      image "Harbor Street Library"
    navigation "Main"
      list
        listitem level=1
          link "Books"
            StaticText "Books"
        listitem level=1
          link "Events"
            StaticText "Events"
        listitem level=1
          link "Visit"
            StaticText "Visit"
  main
    heading "Upcoming events" level=1
      StaticText "Upcoming events"
    article
      heading "Story time for ages 3–5" level=2
        StaticText "Story time for ages 3–5"
      paragraph
        StaticText "Saturdays at 10:30 in the children’s room. "
        link "Details"
          StaticText "Details"
      image
    article
      heading "Repair café" level=2
        StaticText "Repair café"
      paragraph
        StaticText "Bring a lamp, a toaster or a torn jacket. Volunteers fix what they can. "
        link "Partner site"
          StaticText "Partner site"
      image "Volunteers at a workbench fixing a lamp"
    form
      textbox "Your email"
      button
  contentinfo
    paragraph
      StaticText "© 2026 Harbor Street Library · "
      link "[email protected]"
        StaticText "[email protected]"
  button "Back to top"
    StaticText "Back to top"
Logo link name "Harbor Street Library" comes from Contents
Icon ignored: True, hidden: AriaHidden
banner
  navigation Main
main
  form
contentinfo
h1 Upcoming events
  h2 Story time for ages 3–5
  h2 Repair café

What the output shows:

  • Ignored nodes are left out of the outline. The code skips them but still visits their children, which carry their own status. IgnoredReasons on each node says why it was ignored.
  • Names are traced. ComputeName returns the name with its Source (Contents, NativeAttribute, Placeholder and others) and the elements it came from (RelatedElements). The logo link is named from the alt of the image inside it.
  • Hidden content says why. The submit button’s icon is aria-hidden, so it is ignored and the button has no name. That is the button-name failure in Run WCAG checks in CI.
  • Landmarks and headings come from CreateNavigationLists(), which also lists links, form fields and tables, each mapped back to its node.

Each AxNode also has States (checked, expanded, level, required and more), Relations, Focus and Diagnostics (invalid roles, broken ID references, missing required parents). DomNode and SourceBytes lead back to the markup. tree.FocusOrder is the Tab order.

Without a browser, there is no layout. Hiding that depends on layout, such as zero size, clipping or off-screen positioning, isn’t known. With a Chrome DOMSnapshot you capture yourself and pass as RenderedFacts, the tree adds the choices Chromium makes once layout is known (see Rendered facts).

ToChromeJson() writes the tree in the shape of Chrome’s accessibility tree, for comparing with a real browser. How closely it matches is measured in Chrome AX differential. After an edit, tree.Update(edit, restyle) recomputes only what the edit reached, and the result equals a full rebuild. For the page as a sequence of announcements, see Extract visible text.

DOM vs accessibility tree, compared with Chrome’s: The accessibility tree.