Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 25 minutes
  • Examples run with Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

Interactive · step by step How the product card becomes accessibility-tree nodes Step through the card's markup on the left and watch the nodes it gives the tree on the right.
How the product card becomes accessibility-tree nodes, step 10 of 10. Showing article (div class card), h2 (div class title), img (img with no alt), p (span class weight), label + checkbox (div with a span box), two buttons (a div and a span icon), p hidden (not in this version). In focus: label + checkbox, two buttons.articlediv class cardh2div class titleimgimg with no altpspan class weightlabel + checkboxdiv with a span boxtwo buttonsa div and a span iconp hiddennot in this versiononly a mouse can use these divs

The drawing shows the whole scene; its 10 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
  9. 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.
  10. 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:

One product card, two trees

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(', ')})` : ''}`;
}

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

  1. Question 1 of 2 A page holds <div class="toast" hidden>Saved</div>, <span aria-hidden="true">♥</span>, <p>Saved to your list</p> and an empty <div class="spacer"></div>. Which of them gives the accessibility tree a node that a screen reader reads?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The paragraph, Saved to your list

    Hidden content (the hidden attribute, display: none, aria-hidden="true") never reaches assistive technology, and an empty generic div carries no meaning. Only the paragraph arrives as something to read.

  2. Question 2 of 2 What does the accessibility tree hold for the lesson's <p hidden>Back in stock alerts</p>?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Nothing at all, neither the paragraph nor its text

    The model and the scene both leave it out. A script that removes the hidden attribute would bring the paragraph and its text into the tree.

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

  1. Question 1 of 2 What is the accessible name of <button aria-label="Close dialog" title="Close">×</button>?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Close dialog

    The first source in the order that gives text wins: aria-labelledby, aria-label, an associated label, the content, then the title. Here aria-label gives Close dialog.

  2. Question 2 of 2 Which of these is a state in WAI-ARIA's sense, rather than a property?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Whether a checkbox is checked

    WAI-ARIA calls a state a dynamic property that may change in response to user action or automated processes; properties are essential to the nature of the object.

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:

Interactive · step by step One checkbox node, five platform accessibility APIs Step through the platform APIs one at a time and read the role and state each one gives the same node.
One checkbox node, five platform accessibility APIs, step 7 of 7. Showing checkbox node, MSAA + IAccessible2 (ROLE_SYSTEM_CHECKBUTTON), UI Automation (CheckBox, ToggleState Off), ATK/AT-SPI (ROLE_CHECK_BOX), AX API (AXCheckBox, AXValue 0), Android (android.widget.CheckBox).checkbox node"Gift wrap", not checkedMSAA + IAccessible2ROLE_SYSTEM_CHECKBUTTONUI AutomationCheckBox, ToggleState OffATK/AT-SPIROLE_CHECK_BOXAX APIAXCheckBox, AXValue 0Androidandroid.widget.CheckBox

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. UI Automation. UI Automation, the newer Windows API, gives it the control type CheckBox and reports the state through its Toggle pattern, ToggleState Off.
  4. 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.
  5. The macOS AX API. On macOS it is an AXCheckBox whose AXValue is 0, and 1 once it is ticked. VoiceOver reads it there.
  6. 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.
  7. 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

  1. Question 1 of 2 NVDA on Windows reads a page in Chrome. Through which interfaces does the page's tree reach it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The Windows accessibility APIs, MSAA with IAccessible2 and UI Automation

    The browser exposes its tree through the platform's own API. On Windows that means MSAA with IAccessible2 and UI Automation.

  2. Question 2 of 2 Why does one <button> element work with NVDA on Windows, Orca on Linux, VoiceOver on a Mac and TalkBack on Android?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Each browser maps the same button node into its platform's API, using the terms W3C's mapping specifications give

    HTML-AAM and Core-AAM give every role, state and property a term in each platform API, so one correct node reaches every screen reader.

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:

Interactive · message by message A press on Gift wrap, with a screen reader listening Step through one press on each version of the box and follow what reaches the screen reader.
A press on Gift wrap, with a screen reader listening, step 8 of 8. Messages so far: The person to Browser: Space; Browser to Screen reader: state changed; The person to Browser: click.The personkeyboard or pointerBrowsercheckbox, checkedScreen readersays: checkedSpacestate changedclick

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. 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.
  2. The tree changes. The input becomes checked, so the browser changes the node's state in the accessibility tree from not checked to checked.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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

  1. Question 1 of 2 A div is drawn as a checkbox, and a click adds the class on, which draws a tick. A screen reader user hears what, when the box is clicked?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Nothing, because the tree has no checkbox state to change, so no event goes out

    Events come from changes in the tree. A custom checkbox needs role="checkbox" and aria-checked kept in step, or the screen reader stays silent.

  2. Question 2 of 2 A custom checkbox has role="checkbox" aria-checked="false" tabindex="0", and its click handler toggles a class. Screen reader users say it always sounds not checked. What fixes it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Set aria-checked to true or false on every press, and make Space toggle it too

    A state written once into the markup stays that state. The script must keep aria-checked in step, or use a native input type="checkbox".

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

  1. Question 1 of 2 What does Firefox's Accessibility Inspector show?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Firefox's own accessibility tree, the same on every operating system

    Browser tools show the browser's own tree; platform inspectors show what crossed into the API.

  2. Question 2 of 2 A combo box looks right in Chrome's Accessibility tab, but NVDA reads it wrongly. Which tool shows what Chrome actually exposed through UI Automation?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Accessibility Insights for Windows, with Live Inspect

    When the browser's own view looks right and the screen reader still disagrees, inspect the platform layer it reads.

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

  1. Question 1 of 2 A Playwright test with toMatchAriaSnapshot() passes. What has it proved?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: That Playwright's own computation of roles and names matched the template

    A snapshot is a regression check of one tool's computation. Keep screen reader tests and the browser's own tree for the questions it cannot answer.

  2. Question 2 of 2 Can a page's own script read the role and name its browser computed for an element?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: No, there is no standard web API for it; automation such as WebDriver can ask from outside the page

    The tree lives in the browser and the platform API. Scripts can see the DOM, so tools either ask the browser through automation or compute an approximation.

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: true when 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:

  1. '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.
  2. 'no-name:i' when its role is one of button, link, checkbox, radio, textbox, searchbox, combobox or img, and its name is missing, empty or only spaces.
  3. 'no-state:i' when its role is checkbox and states.checked is not true, false or 'mixed', or its role is radio and states.checked is not true or false.

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.

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.

  1. Question 1 of 16 How does the accessibility tree differ from the DOM?

    Choose one answer.

    Show the answer: Question 1 of 16

    Answer: It is built from the DOM and holds only what a person can perceive or operate, each node with a role, a name and states

    WAI-ARIA defines the tree as accessible objects that represent the structure of the user interface. The browser derives it from the DOM.

  2. Question 2 of 16 What does an image with alt="" give the accessibility tree?

    Choose one answer.

    Show the answer: Question 2 of 16

    Answer: Nothing to read, because empty alt marks it as decoration and its role becomes none

    alt="" and no alt are opposites. The first says the image is decoration; the second leaves an image the tree cannot name.

  3. Question 3 of 16 A design system greys out a button by adding the class is-disabled, and the click handler ignores clicks while it is there. What does the accessibility tree show for the button?

    Choose one answer.

    Show the answer: Question 3 of 16

    Answer: An enabled button, because a class never reaches the tree; the disabled attribute or aria-disabled would

    A state that matters to the user must be an attribute the tree reads, here disabled or aria-disabled="true", or the screen reader tells a different story from the screen.

  4. Question 4 of 16 What is the accessible name of the field in <label for="pin">PIN code</label><input id="pin" placeholder="6 digits">?

    Choose one answer.

    Show the answer: Question 4 of 16

    Answer: PIN code

    aria-labelledby, aria-label, the label element, title, placeholder; the first that gives text wins, so the visible label names the field.

  5. Question 5 of 16 According to the note under WCAG 4.1.2, which controls already meet Name, Role, Value?

    Choose one answer.

    Show the answer: Question 5 of 16

    Answer: Standard HTML controls, when they are used according to specification

    The work of 4.1.2 falls on custom controls, which must provide the role, the name, the states and the change notifications themselves.

  6. Question 6 of 16 A volume slider is set to 7 of 10. Which field of its node holds the 7?

    Choose one answer.

    Show the answer: Question 6 of 16

    Answer: Its value

    Role slider, name Volume, value 7. A screen reader reads all three, and announces the new value when it changes.

  7. Question 7 of 16 Through which accessibility API do apps on iOS, Safari among them, expose their interface?

    Choose one answer.

    Show the answer: Question 7 of 16

    Answer: UIKit's own accessibility API, UIAccessibility, which W3C's mapping tables do not cover

    Five platform APIs appear in Core-AAM's tables; iOS has its own UIKit API outside them.

  8. Question 8 of 16 What control type does UI Automation give the node of a <button> element, according to Core-AAM?

    Choose one answer.

    Show the answer: Question 8 of 16

    Answer: Button

    One button, five terms. UI Automation calls it a Button control.

  9. Question 9 of 16 A div with a click handler holds only the text Add to cart. How does Chrome on Android expose it to TalkBack, going by Core-AAM?

    Choose one answer.

    Show the answer: Question 9 of 16

    Answer: As an android.widget.TextView, a piece of text rather than a control

    A meaning the markup never states reaches no platform. The fix is the same everywhere, a real button.

  10. Question 10 of 16 What is an accessibility event?

    Choose one answer.

    Show the answer: Question 10 of 16

    Answer: A message the browser sends through the platform API when something in the tree changes, so assistive technology can announce it

    Core-AAM lists an event for every state change and focus change; they are how WCAG's notification of changes reaches assistive technology.

  11. Question 11 of 16 A menu button opens its menu, but the script never changes aria-expanded="false" on the button. What does a screen reader user hear on the button?

    Choose one answer.

    Show the answer: Question 11 of 16

    Answer: That it is collapsed, although the menu is open

    Every state attribute must follow what the interface does. Set aria-expanded to true when the menu opens and back to false when it closes.

  12. Question 12 of 16 How do you see a whole page as an accessibility tree in Chrome's developer tools?

    Choose one answer.

    Show the answer: Question 12 of 16

    Answer: Turn on the Show accessibility tree toggle in the Accessibility tab of the Elements panel

    The Accessibility tab shows one node; the toggle shows the whole page as a tree.

  13. Question 13 of 16 A page places its sidebar first in the markup but draws it on the right. Which tool shows the order in which a screen reader will meet the parts?

    Choose one answer.

    Show the answer: Question 13 of 16

    Answer: Chrome's Source Order Viewer, which numbers the elements in source order

    Draw it however you like, but a screen reader reads in the order of the source. The viewer shows where the two disagree.

  14. Question 14 of 16 The lesson's model prints a paragraph that a style sheet hides with display: none. Why?

    Choose one answer.

    Show the answer: Question 14 of 16

    Answer: The model reads attributes only, never CSS, so it cannot see what a style sheet hides

    Every tool's computation has edges. Know your tool's, and check surprising results in the browser's own tree.

  15. Question 15 of 16 A cross-browser test must check the role and name that each browser itself computed for a button. What fits?

    Choose one answer.

    Show the answer: Question 15 of 16

    Answer: WebDriver's Get Computed Role and Get Computed Label commands

    Use automation to ask the browser; use in-page tools for fast checks of their own computation.

  16. Question 16 of 16 A snapshot template says only - button. Which buttons does it match?

    Choose one answer.

    Show the answer: Question 16 of 16

    Answer: A button with any name, or none, because a template may leave the name out

    A loose template can pass while a button has lost its name. Write the names and states that matter into the template.

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.

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.