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.
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());
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.
IgnoredReasonson each node says why it was ignored. - Names are traced.
ComputeNamereturns the name with itsSource(Contents,NativeAttribute,Placeholderand others) and the elements it came from (RelatedElements). The logo link is named from thealtof 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 thebutton-namefailure 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.