Web Accessibility (WCAG 2.2) Module 3 – Assistive technology and the accessibility tree
The accessibility tree and platform APIs
How a browser turns the DOM into a tree of roles, names, states and values, how each platform API carries it, and how to inspect and test it.
What you will learn
- Explain how a browser builds the accessibility tree from the DOM, and what it leaves out
- Read the role, name, states and value of a node, and spot a 4.1.2 Name, Role, Value failure
- Trace one node through UI Automation, the macOS protocol, AT-SPI and Android, and its events
- Inspect the tree in the developer tools of Chrome, Firefox and Safari
- Explain why page scripts cannot read the tree, and what testing tools compute instead
Before you start
What you’ll learn · Before you start
What you will learn
- Explain how a browser builds the accessibility tree from the DOM, and what it leaves out
- Read the role, name, states and value of a node, and spot a 4.1.2 Name, Role, Value failure
- Trace one node through UI Automation, the macOS protocol, AT-SPI and Android, and its events
- Inspect the tree in the developer tools of Chrome, Firefox and Safari
- Explain why page scripts cannot read the tree, and what testing tools compute instead
Before you start
On this page
Two versions of one product card can look the same on screen and still give a screen reader two different pages. In the model this lesson runs, the card written with div and span elements gives the accessibility tree 0 buttons, 0 headings and 0 checkboxes; written with the element HTML has for each job, it gives 2 buttons, 1 heading and 1 checkbox, each with a name.
Screen readers, voice control and switch software read that tree, never your markup. You will be able to explain how the browser builds it, read what each node carries, trace a node through each operating system’s platform accessibility API: The interface an operating system gives assistive technology for reading and operating programs: MSAA with IAccessible2 and UI Automation on Windows, the AX API on macOS, ATK and AT-SPI on Linux, Android's accessibility API, and UIKit's on iOS., inspect the tree in three browsers, and say why a page’s own scripts cannot read it.
The browser builds a second tree from the DOM
The browser parses your HTML into the DOM, the tree of elements that styles and scripts work on. From the DOM it builds a second tree for assistive technology. WAI-ARIA calls it the accessibility tree: The tree of objects a browser builds from the DOM for assistive technology: one node for each thing a person can perceive or operate, with its role, name, states and value. More in “Disabilities, barriers and assistive technology”, a “tree of accessible objects that represents the structure of the user interface” (WAI-ARIA 1.2). Each node stands for something a person can perceive or operate, and W3C’s accessibility API mappings: W3C specifications, such as HTML-AAM for HTML elements and Core-AAM for ARIA roles, states and properties, that say which role, state and event each piece of markup becomes in every platform accessibility API. say which node each piece of markup becomes: HTML-AAM for HTML elements, Core-AAM for ARIA roles, states and properties.
Not everything reaches the tree. HTML-AAM notes that display: none or visibility: hidden removes an element from it,
and the hidden attribute does the same through the browser’s default style sheet
(HTML Living Standard). Core-AAM says
an element with aria-hidden="true" should not be exposed. Most div and span elements map to the
generic role: The role of an element that carries no meaning of its own, such as most div and span elements. A generic node groups its content but tells assistive technology nothing about what it is or what it does., a container that tells a screen reader nothing about what it holds. Step
through the lesson’s product card one piece of markup at a time, then the same card written with div and span elements:
The drawing shows the whole scene; its 10 steps are listed below.
Every step as text
- The DOM of the card. The browser has parsed the card into the DOM, seven pieces of markup from the article down to a hidden paragraph. None of them is in the accessibility tree yet.
- A named article. The article element maps to the article role. Its aria-labelledby points at the h2, so the node takes the heading's text as its name.
- A heading with a level. The h2 maps to the heading role, with level 2 taken from the tag. A heading's name comes from its content, Masala chai.
- An image named by its alt. The img has alt text, so it becomes an img node named by it. With alt="" it would be left out as decoration, and with no alt at all it would be an image with no name.
- A paragraph of text. The p element maps to the paragraph role. A paragraph has no name of its own; a screen reader reads its text as it comes.
- A checkbox with a state. The input is a checkbox. The label around it gives the name, Gift wrap, and the checked attribute, absent here, gives the state, not checked.
- Two named buttons. Each button element becomes a button node named by its text. The heart icon inside Save is aria-hidden, so that name is the word Save alone.
- The hidden paragraph. The paragraph with the hidden attribute gives the tree nothing, and neither would anything inside it. The article and the six nodes inside it describe the card.
- The same card in divs. Now the same card written with div and span elements and a script that listens for clicks. On screen it looks exactly like the first one.
- No role to find. Every div and span is generic, so none of these nodes exists in this version. The tree holds four runs of text and an image with no name, and the divs do not even take keyboard focus.
An article and the six nodes inside it describe the first card, and the hidden paragraph gives it nothing. The second card gives the tree only text and an image without a name. The program below builds both trees with a small model of HTML-AAM’s rules and prints one line per node: the role, the name in quotes and any states in brackets. The model reads attributes only, never CSS, so the browser’s own tree, which a later section opens in its developer tools, is always the reference:
tree/tree_snapshot.mjs
// One product card written twice: with div and span elements styled to look right, and with the
// element HTML has for each job. aria_model.mjs builds the accessibility-tree nodes of each version
// and prints them one per line: the role, the name in quotes, the states in brackets.
import { census, snapshot } from './aria_model.mjs';
const divs = `
<div class="card">
<div class="title">Masala chai</div>
<img src="chai.jpg">
<span class="weight">Pack of 250 g</span>
<div class="option" data-action="toggle"><span class="box"></span> Gift wrap</div>
<div class="button" data-action="add">Add to cart</div>
<span class="icon heart" data-action="save"></span>
</div>`;
const native = `
<article aria-labelledby="chai-title">
<h2 id="chai-title">Masala chai</h2>
<img src="chai.jpg" alt="A glass of masala chai">
<p>Pack of 250 g</p>
<label><input type="checkbox"> Gift wrap</label>
<button type="button">Add to cart</button>
<button type="button" aria-pressed="false"><span aria-hidden="true">♥</span> Save</button>
<p hidden>Back in stock alerts</p>
</article>`;
const versions = [
['Written with div and span elements', divs],
['Written with an HTML element for each job', native],
];
for (const [title, html] of versions) {
console.log(`${title}:`);
for (const line of snapshot(html)) console.log(` ${line}`);
console.log(` In all: ${census(html)}`);
console.log('');
}
// A press on Gift wrap. The real checkbox becomes checked; the div version only gains a class for its style sheet.
const pressedNative = snapshot(native.replace('<input type="checkbox">', '<input type="checkbox" checked>'));
const pressedDivs = snapshot(divs.replace('class="box"', 'class="box on"'));
console.log('After a press on Gift wrap:');
console.log(` HTML elements: ${pressedNative.find((line) => line.includes('checkbox')).trim()}`);
const unchanged = pressedDivs.join('\n') === snapshot(divs).join('\n');
console.log(` div and span: ${unchanged ? 'the tree is the same as before, so there is no new state to report' : 'the tree changed'}`); Output
Written with div and span elements:
- text: Masala chai
- img
- text: Pack of 250 g
- text: Gift wrap
- text: Add to cart
In all: 0 buttons, 0 headings, 0 checkboxes; 1 without a name (img)
Written with an HTML element for each job:
- article "Masala chai":
- heading "Masala chai" [level=2]
- img "A glass of masala chai"
- paragraph: Pack of 250 g
- checkbox "Gift wrap" [checked=false]
- button "Add to cart"
- button "Save" [pressed=false]
In all: 2 buttons, 1 heading, 1 checkbox; 0 without a name
After a press on Gift wrap:
HTML elements: - checkbox "Gift wrap" [checked=true]
div and span: the tree is the same as before, so there is no new state to report
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node tree_snapshot.mjs
tree/aria_model.mjs
// A small model of how a browser turns HTML into accessibility-tree nodes. It follows a part of
// W3C's HTML Accessibility API Mappings: the role of each element and the order in which a name is
// found (aria-labelledby, aria-label, the element's own label, alt or content, then title). It reads
// attributes only, never CSS, so display: none in a style sheet and text added with ::before are
// invisible to it. The browser's own tree, seen in its developer tools, is always the reference.
const VOID = new Set(['img', 'input', 'br', 'hr']);
const INPUT_ROLES = { text: 'textbox', email: 'textbox', search: 'searchbox', checkbox: 'checkbox', radio: 'radio', submit: 'button', button: 'button' };
// Roles whose name may come from their content (a button's text, a heading's text).
const FROM_CONTENT = new Set(['button', 'link', 'heading', 'checkbox', 'radio', 'tab', 'menuitem', 'option', 'switch']);
// Roles a person operates or must identify: a node of one of these needs a name.
const NEEDS_NAME = new Set(['button', 'link', 'checkbox', 'radio', 'textbox', 'searchbox', 'combobox', 'img']);
const clean = (s) => s.replace(/\s+/g, ' ').trim();
/** Parses a small, well-formed HTML fragment into element nodes ({ tag, attrs, children }) and text nodes ({ text }). */
export function parse(html) {
const root = { tag: '#root', attrs: {}, children: [], parent: null };
const stack = [root];
const token = /<\/([a-z0-9]+)\s*>|<([a-z0-9]+)((?:\s+[a-z-]+(?:="[^"]*")?)*)\s*\/?>|([^<]+)/gi;
for (const m of html.matchAll(token)) {
const top = stack[stack.length - 1];
if (m[1]) stack.pop();
else if (m[2]) {
const attrs = {};
for (const a of m[3].matchAll(/([a-z-]+)(?:="([^"]*)")?/gi)) attrs[a[1]] = a[2] ?? '';
const el = { tag: m[2].toLowerCase(), attrs, children: [], parent: top };
top.children.push(el);
if (!VOID.has(el.tag)) stack.push(el);
} else if (m[4].trim()) top.children.push({ text: m[4] });
}
return root;
}
/** Left out of the tree: the hidden attribute, aria-hidden="true" and hidden inputs. */
const hidden = (el) => 'hidden' in el.attrs || el.attrs['aria-hidden'] === 'true' || (el.tag === 'input' && el.attrs.type === 'hidden');
/** The element's role: an explicit role attribute, else the one HTML-AAM gives the element. */
export function roleOf(el) {
const explicit = (el.attrs.role ?? '').trim().split(/\s+/)[0];
if (explicit) return explicit === 'presentation' ? 'none' : explicit;
if (/^h[1-6]$/.test(el.tag)) return 'heading';
if (el.tag === 'input') return INPUT_ROLES[el.attrs.type ?? 'text'] ?? 'textbox';
if (el.tag === 'img') return el.attrs.alt === '' ? 'none' : 'img';
if (el.tag === 'a') return 'href' in el.attrs ? 'link' : 'generic';
const roles = { button: 'button', select: 'combobox', textarea: 'textbox', ul: 'list', ol: 'list', li: 'listitem', p: 'paragraph', article: 'article', nav: 'navigation', main: 'main' };
return roles[el.tag] ?? 'generic';
}
function find(node, test) {
for (const c of node.children ?? []) {
if ('text' in c) continue;
if (test(c)) return c;
const hit = find(c, test);
if (hit) return hit;
}
return null;
}
/** The text of an element's content, as a name from content: hidden parts skipped, an image's alt read. */
function textOf(el) {
const parts = el.children.map((c) => ('text' in c ? c.text : hidden(c) ? '' : (c.attrs['aria-label'] ?? (c.tag === 'img' ? (c.attrs.alt ?? '') : textOf(c)))));
return clean(parts.join(' '));
}
/** The accessible name, in HTML-AAM's order; '' when nothing gives one. */
export function nameOf(el, doc) {
const ids = (el.attrs['aria-labelledby'] ?? '').split(/\s+/).filter(Boolean);
const byIds = clean(ids.map((id) => find(doc, (n) => n.attrs.id === id)).filter(Boolean).map(textOf).join(' '));
if (byIds) return byIds;
if (clean(el.attrs['aria-label'] ?? '')) return clean(el.attrs['aria-label']);
if (['input', 'select', 'textarea'].includes(el.tag)) {
let label = el.parent;
while (label && label.tag !== 'label') label = label.parent;
label ??= el.attrs.id ? find(doc, (n) => n.tag === 'label' && n.attrs.for === el.attrs.id) : null;
// Only a button-like input is named by its value; a text field's value is what was typed, never its name.
const ownValue = ['submit', 'reset', 'button'].includes(el.attrs.type) ? el.attrs.value : undefined;
return (label && textOf(label)) || clean(ownValue ?? el.attrs.title ?? el.attrs.placeholder ?? '');
}
if (el.tag === 'img') return clean(el.attrs.alt ?? el.attrs.title ?? '');
if (FROM_CONTENT.has(roleOf(el)) && textOf(el)) return textOf(el);
return clean(el.attrs.title ?? '');
}
function statesOf(el, role) {
const s = [];
if (role === 'heading') s.push(`level=${el.attrs['aria-level'] ?? el.tag.slice(1)}`);
if (role === 'checkbox' || role === 'radio') s.push(`checked=${el.tag === 'input' ? 'checked' in el.attrs : el.attrs['aria-checked'] === 'true'}`);
for (const a of ['expanded', 'pressed']) if (`aria-${a}` in el.attrs) s.push(`${a}=${el.attrs[`aria-${a}`]}`);
if ('disabled' in el.attrs || el.attrs['aria-disabled'] === 'true') s.push('disabled');
return s;
}
/** The nodes of the tree in reading order: generic and none nodes are left out and their children move up. */
export function tree(html) {
const doc = parse(html);
const out = [];
const walk = (node, depth, inLabel) => {
for (const c of node.children) {
if ('text' in c) {
// Text inside a label is printed only as the name of its control.
if (!inLabel) out.push({ depth, role: 'text', name: clean(c.text) });
continue;
}
if (hidden(c)) continue;
const role = roleOf(c);
const label = inLabel || c.tag === 'label';
if (role === 'generic' || role === 'none') {
walk(c, depth, label);
continue;
}
const node = { depth, role, name: nameOf(c, doc), states: statesOf(c, role), focusable: ['a', 'button', 'input', 'select', 'textarea'].includes(c.tag) || 'tabindex' in c.attrs };
out.push(node);
if (role === 'paragraph') node.text = textOf(c);
else if (!FROM_CONTENT.has(role) && role !== 'img') walk(c, depth + 1, label);
}
};
walk(doc, 0, false);
return out;
}
/** One line per node, in the node format of Playwright's ARIA snapshots: - role "name" [state]. */
export function snapshot(html) {
const nodes = tree(html);
return nodes.map((n, i) => {
const pad = ' '.repeat(n.depth);
if (n.role === 'text') return `${pad}- text: ${n.name}`;
const head = `${pad}- ${n.role}${n.name ? ` "${n.name}"` : ''}${n.states.map((s) => ` [${s}]`).join('')}`;
if (n.text) return `${head}: ${n.text}`;
return nodes[i + 1]?.depth > n.depth ? `${head}:` : head;
});
}
/** How many buttons, headings and checkboxes the tree holds, and which nodes that need a name have none. */
export function census(html) {
const nodes = tree(html);
const count = (role, one, many) => {
const n = nodes.filter((x) => x.role === role).length;
return `${n} ${n === 1 ? one : many}`;
};
const unnamed = nodes.filter((x) => NEEDS_NAME.has(x.role) && !x.name).map((x) => x.role);
return `${count('button', 'button', 'buttons')}, ${count('heading', 'heading', 'headings')}, ${count('checkbox', 'checkbox', 'checkboxes')}; ${unnamed.length} without a name${unnamed.length ? ` (${unnamed.join(', ')})` : ''}`;
} Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The div version prints five lines, and its census reads 0 buttons, 0 headings and 0 checkboxes, with one image that
has no name. The other version prints an article named Masala chai that holds six nodes: a heading of level 2, a named
image, a paragraph, a checkbox that is not checked and two buttons. The last two lines press Gift wrap. The real
checkbox now reads [checked=true], while the div version’s tree is the same as before, because its tick lives only in
a class that the style sheet draws.
Give the div a role
Press Edit and add role="checkbox" aria-checked="false" tabindex="0" to the div with the class option, then run
it. The div version now prints checkbox "Gift wrap" [checked=false] and its census counts 1 checkbox. The press
still changes nothing in its tree, because the script only adds a class: the role is there, the state is not kept.
That edit shows the two halves of every custom control. A role tells the tree what the node is; only a script that keeps the state attribute in step tells it what changed.
Key idea
The tree is built from the markup: change an element or an attribute and the tree follows; change a class that only draws something, and a screen reader meets the same page as before.
Checkpoint
Check: How the tree is built from the DOM
A node carries a role, a name, states and a value
Read one line of the output: checkbox "Gift wrap" [checked=false]. It answers the three questions a screen reader
needs answered before it can say anything useful: what kind of thing this is, what it is called and what condition it
is in. A control that holds something, such as a quantity box or a slider, answers a fourth question with its value,
and a node may also carry a description, extra help beyond its name:
| Field | The question it answers | In the card |
|---|---|---|
| Role | What kind of thing is it? | checkbox, heading, button |
| Name | What is it called? | Gift wrap, Masala chai |
| States and properties | What condition is it in? | not checked; level 2 for the heading |
| Value | What does it hold now? | none: the card has no slider or text field |
| Description | Is there more help? | none here |
WAI-ARIA separates the two kinds of condition. A property is one of the attributes “essential to the nature of a given object”, such as a heading’s level, while a state is “a dynamic property” that may change “in response to user action or automated processes” (WAI-ARIA 1.2). Checked changes when the box is ticked; the heading’s level stays 2.
The accessible name: The name a browser computes for an element and gives assistive technology, taken in a fixed order from aria-labelledby, aria-label, a label element, the alt text or the content, then the title. A screen reader announces it and voice control matches commands against it. is computed in a fixed order, and the first source that gives
text wins. For a button, HTML-AAM looks at aria-labelledby and aria-label first, then an associated label element,
then the button’s own content and last its title. A text field has no content step: after its label come title
and then placeholder. An image goes from the ARIA attributes to alt, then title. The order explains a common
failure, an aria-label on a button quietly replacing its visible text as the name.
WCAG turns these fields into one requirement. 4.1.2 Name, Role, Value, at Level A, asks that for every user interface component “the name and role can be programmatically determined”, that what a user can set “can be programmatically set”, and that “notification of changes to these items is available” to assistive technology (WCAG 2.2). Its note adds that standard HTML controls already meet it “when used according to specification” (Understanding 4.1.2). The work falls on custom controls.
An aria-label that hides the visible text
<button aria-label="Submit message form">Send</button> gets the name Submit message form, so a screen reader never
says Send and a voice control user who says “click Send” reaches nothing. Leave the name to the visible text
(<button>Send</button>), or start the aria-label with it.
Checkpoint
Check: A node's role, name, states and value
One tree reaches five platform APIs
A browser does not talk to screen readers directly. Each operating system offers an interface through which assistive technology reads and operates other programs, and the browser exposes its tree there. Core-AAM 1.2 gives mappings for five: MSAA with IAccessible2 and UI Automation on Windows, ATK and AT-SPI on Linux, the macOS accessibility protocol, known as the AX API, and Android’s accessibility API. Apps on iOS, Safari among them, use UIKit’s own accessibility API, UIAccessibility, which W3C’s tables do not cover.
Each API has its own vocabulary, and the mapping specifications are the dictionaries: for every role, state and property they give the term each API uses. Follow the Gift wrap checkbox through all five:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- One node in the tree. The browser's tree holds one node for the Gift wrap box, with the role checkbox, the name Gift wrap and the state not checked. How it leaves the browser depends on the operating system.
- MSAA with IAccessible2. On Windows, browsers expose the node through MSAA with IAccessible2, as the role ROLE_SYSTEM_CHECKBUTTON. Ticked, it would add the state STATE_SYSTEM_CHECKED.
- UI Automation. UI Automation, the newer Windows API, gives it the control type CheckBox and reports the state through its Toggle pattern, ToggleState Off.
- ATK and AT-SPI. On Linux the node is ROLE_CHECK_BOX with the state STATE_CHECKABLE; STATE_CHECKED would be added once it is ticked. The Orca screen reader reads it there.
- The macOS AX API. On macOS it is an AXCheckBox whose AXValue is 0, and 1 once it is ticked. VoiceOver reads it there.
- Android. On Android the role maps to android.widget.CheckBox in the accessibility framework that TalkBack reads. W3C's tables still list the checked state's Android mapping as to be decided.
- Same meaning everywhere. Five vocabularies carry one meaning, a checkbox called Gift wrap that is not checked. Each screen reader reads the API of its own platform, so one correct node in the browser's tree reaches all of them.
Five vocabularies carry one meaning, and the same holds for every role. In Core-AAM’s tables a button is ROLE_SYSTEM_PUSHBUTTON in MSAA, the control type Button in UI Automation, ROLE_PUSH_BUTTON in ATK/AT-SPI, AXButton on macOS and android.widget.Button on Android. A heading is IA2_ROLE_HEADING, a Text control with the localized type heading, ROLE_HEADING, AXHeading and, on Android, a View, or a TextView when it holds only text, marked as a heading.
That is why one set of markup works with NVDA on Windows, Orca on Linux, VoiceOver on a Mac and TalkBack on a phone,
and why a meaning the markup never states reaches none of them. The generic role arrives as a group or a section on
the desktop APIs and, on Android, as an android.view.View, or a TextView when it holds only text: a container,
never a control.
Checkpoint
Check: The platform accessibility APIs
Changes reach assistive technology as events
A screen reader does not keep reading the whole tree again. When something changes, the browser fires an accessibility event: A message the browser sends through the platform accessibility API when something in the accessibility tree changes, such as a checkbox being ticked or focus moving, so that assistive technology can announce it. through the platform API, and the screen reader announces what changed. Core-AAM lists an event for every state; when a checkbox’s checked state changes, the browser sends this one on each desktop platform:
| Platform API | The event when checked changes |
|---|---|
| MSAA + IAccessible2 | EVENT_OBJECT_STATECHANGE |
| UI Automation | PropertyChangedEvent, for the ToggleState of the Toggle pattern |
| ATK/AT-SPI | object:state-changed:checked |
| AX API | AXValueChanged |
These events are the notification of changes that 4.1.2 asks for, and a browser can only send them for changes it sees in the tree. Press each version of Gift wrap with a screen reader listening:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- A press on the checkbox. Focus is on the real Gift wrap checkbox and the person presses Space. A screen reader in its reading mode takes the key and asks the browser to press the box, so the press arrives either way.
- The tree changes. The input becomes checked, so the browser changes the node's state in the accessibility tree from not checked to checked.
- An event goes out. The browser fires a state-change event through the platform API, such as object:state-changed:checked in AT-SPI or a ToggleState change in UI Automation.
- Announced. The screen reader receives the event and announces the new state. This is the notification of changes that WCAG 4.1.2 asks for.
- A click on the div box. Now the div version. Its box cannot take keyboard focus, so a mouse user clicks it, and a script adds the class on, which draws a tick.
- Nothing in the tree. The class changes only how the style sheet draws the box. The tree has no checkbox node and no checked state, so there is nothing in it to update.
- Silence. No event goes out and the screen reader says nothing. A blind user cannot tell that the box is ticked, or that it was ever a box at all.
- The fix. An input of type checkbox, or role=checkbox with aria-checked that the script keeps in step, gives the tree a state to change and the browser an event to fire.
The div version failed before the screen reader played any part. Its box could not take keyboard focus, and its tick
lived only in a class, so the tree had no state to change and no event went out. A custom control needs what the native
one has built in: a role, a state attribute that the script keeps in step on every press, such as aria-checked, and
keyboard focus with the keys its role implies. Menus that open, toggle buttons and disabled controls follow the same
rule with aria-expanded, aria-pressed and aria-disabled, each with its own event in Core-AAM’s tables.
A state set once and never again
role="checkbox" aria-checked="false" written into the markup, with a script that toggles only a class, gives the
tree a checkbox that is never checked. Every press must set aria-checked to true or false as well, or the
screen reader keeps announcing the old state.
Checkpoint
Check: Changes that reach assistive technology
Developer tools show the browser’s own tree
You do not need a screen reader to read the tree. Every major browser shows its own in its developer tools, and each desktop operating system has an inspector for what reaches its API:
| Tool | What it shows | Where to find it |
|---|---|---|
| Chrome DevTools | Chrome’s tree and a node’s computed properties | Elements panel, Accessibility tab |
| Firefox Accessibility Inspector | Firefox’s tree: role, name, states, actions, relations | Accessibility tab of the toolbox |
| Safari Web Inspector | The accessibility data of the selected node | Node panel in the Elements tab’s sidebar |
| Accessibility Insights for Windows | The UI Automation tree of any app | Live Inspect, by hover or keyboard focus |
| Accessibility Inspector | The accessibility hierarchy of a running Mac app | Xcode, Open Developer Tool |
In Chrome, the Show accessibility tree toggle swaps the Elements panel’s DOM tree for a tree of the whole page, and the Source Order Viewer numbers the elements in the order of the source (Chrome DevTools), the order in which a screen reader meets them. Firefox’s inspector can show the tabbing order, list only the nodes with contrast, keyboard or text-label issues, and print a tree as JSON; it shows Firefox’s own tree “rather than information from the platform accessibility layer”, so it reads the same on every system (Firefox Source Docs). Safari’s Node panel gives the selected node’s accessibility data.
The last two rows read the platform layer instead. Accessibility Insights for Windows displays elements “in the UIA Tree”, and Apple’s Accessibility Inspector displays and queries the accessibility information of an app’s elements. When a node looks right in the browser’s own tool and still misbehaves, they show what actually crossed into the API.
Inspect this page
Open your browser’s developer tools on this lesson and find the next checkpoint’s Check button in the accessibility pane. Its visible word is Check, but its name is longer, “Check the answer” and the question’s number, because the button also holds words that are hidden visually. The visible word starts the name, which matters to voice control, as the last lesson of this module shows.
Checkpoint
Check: Inspecting the tree
Page scripts cannot read the tree, so tests compute their own
No standard web API lets a page’s own script read the accessibility tree its browser built. The Accessibility Object Model explainer, from W3C’s Web Platform Incubator Community Group, describes the gap: developers “have no way to probe or test how ARIA and other accessible properties are applied”. It records that its proposed interface for reading the computed tree was abandoned, and that similar functionality is being reconsidered as a test-only API (AOM explainer).
So every testing tool either asks the browser from outside the page or computes roles and names itself:
| Way to read roles and names | Whose computation | Good for |
|---|---|---|
| WebDriver’s Get Computed Role and Get Computed Label | The browser’s own, asked by automation | A few nodes in a cross-browser test |
| Developer tools and platform inspectors | The browser’s own, or the platform API’s | Debugging one page by hand |
| Playwright’s ARIA snapshots | Playwright’s code, running in the page | Regression tests of whole regions |
| axe-core | Deque’s code, running in the page | Automated rule checks |
| This lesson’s model | A few HTML-AAM rules, without CSS | Reading the rules, testing what you know |
The WebDriver commands return the browser’s own computed role
and accessible name for an element. Playwright’s ARIA snapshots give “a YAML representation of the accessibility tree
of a page”: locator.ariaSnapshot() writes one for a region, and expect(locator).toMatchAriaSnapshot() compares the
region with a template that “is a constraint, not necessarily a complete serialization”, so a test can leave out names
or states, or match a name with a pattern (Playwright). The roles and names in that YAML come from Playwright’s own
functions, getAriaRole and getElementAccessibleName, which follow HTML-AAM and the accessible name specification
(roleUtils.ts). axe-core has its
own name computation too (accessible-text-virtual.js).
Each computation is an approximation of what browsers do, and each has edges. This lesson’s model shows one plainly: a paragraph hidden by a rule in a style sheet stays in its printout, while every browser leaves it out. A snapshot that passes proves that one tool’s computation matched the template; what a screen reader then says is tested with the screen reader, which the next two lessons do.
Website Accessibility Checker (WCAG 2.2) Run axe-core's WCAG 2.2 checks on a page and see the nodes it finds without a role or a name.Checkpoint
Check: What test tools compute
Practise: find the failures in a tree
The exercise gives you trees as lists of nodes, like the ones the model prints, and asks for the nodes that would fail 4.1.2: focusable nodes with no role, controls and images with no name, and checkboxes or radio buttons with no checked state. Its sample tests run in your browser:
Exercise · Easy · JavaScript
Find the Name, Role, Value failures in a tree
A tree snapshot lists the nodes a browser exposes, in reading order. Write treeProblems(nodes) in problems.mjs so that it reports the nodes that would fail WCAG 4.1.2 Name, Role, Value.
Each node is an object with these fields:
role: a string, such as'button','checkbox','img'or'generic'.name: the accessible name, a string (it may be empty, or missing).focusable:truewhen the keyboard can reach the node.states: an object, such as{ checked: false }; it may be missing.
For each node in turn (its index i in nodes), add these problems to the result, in this order:
'no-role:i'when the node is focusable and its role is'generic'or'none': the keyboard reaches it, but nothing says what it is.'no-name:i'when its role is one ofbutton,link,checkbox,radio,textbox,searchbox,comboboxorimg, and its name is missing, empty or only spaces.'no-state:i'when its role ischeckboxandstates.checkedis nottrue,falseor'mixed', or its role isradioandstates.checkedis nottrueorfalse.
A tree with no problems returns []. The sample tests import treeProblems from problems.mjs and run in your browser.
Starter code · problems.mjs
/** True when s is a string with something other than spaces in it. */
function filled(s) {
return typeof s === 'string' && s.trim() !== '';
}
/** The 4.1.2 problems of a tree snapshot: 'no-role:i', 'no-name:i', 'no-state:i'. */
export function treeProblems(nodes) {
const problems = [];
nodes.forEach((node, i) => {
if (node.focusable && (node.role === 'generic' || node.role === 'none')) problems.push(`no-role:${i}`);
// Add the name check and the state check here.
});
return problems;
} The sample tests · problems.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { treeProblems } from './problems.mjs';
// The lesson's product card, written with an HTML element for each job.
const native = [
{ role: 'article', name: 'Masala chai', focusable: false },
{ role: 'heading', name: 'Masala chai', focusable: false, states: { level: 2 } },
{ role: 'img', name: 'A glass of masala chai', focusable: false },
{ role: 'paragraph', name: '', focusable: false },
{ role: 'checkbox', name: 'Gift wrap', focusable: true, states: { checked: false } },
{ role: 'button', name: 'Add to cart', focusable: true },
{ role: 'button', name: 'Save', focusable: true, states: { pressed: false } },
];
test('the card written with HTML elements has no problems', () => {
assert.deepEqual(treeProblems(native), []);
});
test('a focusable node with no role is reported, a plain text wrapper is not', () => {
const nodes = [
{ role: 'generic', name: '', focusable: false },
{ role: 'generic', name: '', focusable: true },
{ role: 'none', name: '', focusable: true },
];
assert.deepEqual(treeProblems(nodes), ['no-role:1', 'no-role:2']);
});
test('an image and a link without a name are reported', () => {
const nodes = [
{ role: 'img', name: '', focusable: false },
{ role: 'link', focusable: true },
{ role: 'heading', name: '', focusable: false, states: { level: 2 } },
];
assert.deepEqual(treeProblems(nodes), ['no-name:0', 'no-name:1']);
});
test('a name of only spaces is no name', () => {
assert.deepEqual(treeProblems([{ role: 'button', name: ' ', focusable: true }]), ['no-name:0']);
assert.deepEqual(treeProblems([{ role: 'textbox', name: 'Email', focusable: true }]), []);
});
test('a checkbox needs a checked state: true, false or mixed', () => {
const nodes = [
{ role: 'checkbox', name: 'Gift wrap', focusable: true },
{ role: 'checkbox', name: 'All items', focusable: true, states: { checked: 'mixed' } },
{ role: 'checkbox', name: 'Gift wrap', focusable: true, states: { checked: 'yes' } },
{ role: 'checkbox', name: 'Gift wrap', focusable: true, states: { checked: true } },
];
assert.deepEqual(treeProblems(nodes), ['no-state:0', 'no-state:2']);
});
test('a radio button is never mixed', () => {
const nodes = [
{ role: 'radio', name: 'Card', focusable: true, states: { checked: true } },
{ role: 'radio', name: 'Cash on delivery', focusable: true, states: { checked: 'mixed' } },
{ role: 'radio', name: 'Net banking', focusable: true, states: { checked: false } },
];
assert.deepEqual(treeProblems(nodes), ['no-state:1']);
});
test('problems of one node come in the order of the rules', () => {
const nodes = [
{ role: 'checkbox', name: '', focusable: true, states: {} },
{ role: 'generic', name: '', focusable: true },
];
assert.deepEqual(treeProblems(nodes), ['no-name:0', 'no-state:0', 'no-role:1']);
});
test('an empty tree has no problems', () => {
assert.deepEqual(treeProblems([]), []);
}); A hint
Keep two sets, const NEEDS_NAME = new Set(['button', 'link', …]) and the roles that mean "nothing" ('generic' and 'none'), and one helper, filled(s), that is true for a string with something other than spaces in it. A missing states object is undefined, so read the state as node.states?.checked.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then test yourself on the six ideas of this lesson, from how the tree is built to what test tools compute. The questions start where your answers have been weakest:
Practice that adapts to you
Practise this lesson
16 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What is a platform accessibility API, and why does a web page need one?
It is the interface an operating system gives assistive technology for reading and operating other programs: MSAA with IAccessible2 and UI Automation on Windows, the AX API on macOS, ATK and AT-SPI on Linux, and Android’s accessibility API. A screen reader never parses your HTML. The browser builds an accessibility tree from the DOM and exposes each node through the platform’s API, with a role, a name, states and a value, and it fires events there when something changes. W3C’s Core-AAM and HTML-AAM say how every element and ARIA attribute maps to each API, which is why one set of markup works with NVDA, VoiceOver, Orca and TalkBack. A page needs the API because it is the only way its meaning reaches the person.
Two harder questions of this module start from this lesson’s trees and events:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
Your team's design system renders every button as a div with a click handler and a class that draws it. Make the case for changing it, using what the accessibility tree, the platform APIs and assistive technology receive from each version.
-
Mid to senior
Your CI runs Playwright tests with toMatchAriaSnapshot on every page. A developer says that because they pass, screen reader testing is no longer needed. What do you answer, and what would you add to the testing?
Key takeaways
- The browser builds an accessibility tree from the DOM, with a node for what a person can perceive or use, and leaves out what is hidden; most div and span elements arrive as generic containers.
- Every node answers what it is, what it is called and what condition it is in, with a role, a name computed in a fixed order, states and properties, and sometimes a value; WCAG 4.1.2 requires all of them for every component.
- The browser exposes the same tree through each platform’s accessibility API, and W3C’s mapping specifications give every role and state its term in MSAA, UI Automation, ATK/AT-SPI, the AX API and Android.
- Changes reach assistive technology as events, and a browser can only send them for changes it sees in the tree, so a custom control keeps its state attribute in step on every press.
- Developer tools show each browser’s own tree, and Accessibility Insights and Xcode’s Accessibility Inspector show what crossed into the platform’s API.
- No standard web API lets a page script read the tree, so test tools ask the browser through WebDriver or compute the roles and names themselves, and a passing snapshot is never proof of what a screen reader says.
Terms in this lesson
- accessibility API mappings
- W3C specifications, such as HTML-AAM for HTML elements and Core-AAM for ARIA roles, states and properties, that say which role, state and event each piece of markup becomes in every platform accessibility API.
- accessibility event
- A message the browser sends through the platform accessibility API when something in the accessibility tree changes, such as a checkbox being ticked or focus moving, so that assistive technology can announce it.
- accessible name
- The name a browser computes for an element and gives assistive technology, taken in a fixed order from aria-labelledby, aria-label, a label element, the alt text or the content, then the title. A screen reader announces it and voice control matches commands against it.
- generic role
- The role of an element that carries no meaning of its own, such as most div and span elements. A generic node groups its content but tells assistive technology nothing about what it is or what it does.
- platform accessibility API
- The interface an operating system gives assistive technology for reading and operating programs: MSAA with IAccessible2 and UI Automation on Windows, the AX API on macOS, ATK and AT-SPI on Linux, Android's accessibility API, and UIKit's on iOS.
References
- HTML Accessibility API Mappings 1.0 (W3C)
- Core Accessibility API Mappings 1.2 (W3C)
- Accessible Rich Internet Applications (WAI-ARIA) 1.2 (W3C)
- HTML Living Standard: the hidden attribute (WHATWG)
- Web Content Accessibility Guidelines (WCAG) 2.2: 4.1.2 Name, Role, Value (W3C)
- Understanding WCAG 2.2: Name, Role, Value (W3C Web Accessibility Initiative)
- UIAccessibility (UIKit) (Apple)
- Chrome DevTools: accessibility features reference (Google)
- Firefox DevTools: Accessibility Inspector (Mozilla)
- Web Inspector: Elements tab (WebKit)
- Accessibility Insights for Windows (Microsoft)
- Accessibility Inspector (Apple)
- Accessibility Object Model explainer (W3C Web Platform Incubator Community Group)
- WebDriver: Get Computed Role and Get Computed Label (W3C)
- Playwright: ARIA snapshots (Microsoft)
- Playwright source: how roles and accessible names are computed (roleUtils.ts) (Microsoft)
- axe-core source: the accessible name computation (accessible-text-virtual.js) (Deque Systems)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress