How to check accessibility tree

Open DevTools, Elements panel, the Accessibility pane, and read role, name and state for the selected node. For a whole page, call Accessibility.getFullAXTree over the DevTools protocol. On the test page below, four button elements produced four button nodes, and one of them came back with an empty name.

Why check this

Run this on any screen with icon buttons or custom widgets, and after a design-system upgrade, before staging sign-off. The accessibility tree is what the platform hands to assistive technology. The DOM is not, and the two differ on every real page.

The defect it catches is a control with no accessible name. A button whose only content is an SVG icon arrives in the tree as button "". Nothing can address it: not assistive technology, not voice control, not a Playwright locator asking for a button by name. The suite loses the element at the same moment the user does.

What this procedure does not do

It does not run a screen reader. None is installed on the machine that produced the output below, and a page reporting announcements it never heard would be worth nothing. It reads the source those announcements are built from: the role, name and state Chrome exposed to the platform accessibility API. An empty name in the tree is an empty name for every screen reader on every platform. The wording each tool speaks around that name belongs to the tool, and this page does not claim it.

Prerequisites

// server.mjs
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
const port = Number(process.argv[2] || 8742);
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="80" height="60">' +
            '<rect width="80" height="60" fill="#999"/></svg>';
createServer(async (req, res) => {
  const path = req.url.split('?')[0];
  if (path.endsWith('.svg')) {
    res.writeHead(200, { 'content-type': 'image/svg+xml' });
    return res.end(svg);
  }
  try {
    const body = await readFile(new URL('.' + path, import.meta.url));
    res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
    res.end(body);
  } catch {
    res.writeHead(404, { 'content-type': 'text/plain' });
    res.end('not found');
  }
}).listen(port, () => console.log('serving on http://127.0.0.1:' + port + '/'));
<!-- tree.html -->
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Order summary</title></head>
<body>
  <h1>Order summary</h1>
  <nav aria-label="Order steps"><a href="#items">Items</a></nav>
  <main>
    <button id="save">Save order</button>
    <button id="close" aria-label="Close dialog">&times;</button>
    <button id="print"><svg width="16" height="16" aria-hidden="true"><rect width="16" height="16"/></svg></button>
    <button id="pay" disabled>Pay now</button>
    <div id="fake" onclick="alert(1)">Cancel order</div>
    <p aria-hidden="true">Prices include VAT.</p>
    <span id="hint" hidden>Saved 2 minutes ago</span>
  </main>
</body>
</html>
// ax-tree.mjs
import { launch } from 'puppeteer-core';
const CHROME = process.env.CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const browser = await launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage();
const cdp = await page.createCDPSession();
await page.goto(process.argv[2], { waitUntil: 'networkidle2' });
const { nodes } = await cdp.send('Accessibility.getFullAXTree');
for (const n of nodes) {
  const role = n.role?.value ?? '';
  if (n.ignored || role === 'StaticText' || role === 'InlineTextBox') continue;
  const state = (n.properties ?? [])
    .filter((p) => ['disabled', 'focusable', 'checked', 'expanded', 'level'].includes(p.name))
    .map((p) => `${p.name}=${p.value.value}`)
    .join(' ');
  console.log(role.padEnd(14), JSON.stringify(n.name?.value ?? '').padEnd(16), state);
}
await browser.close();
// ax-name.mjs
import { launch } from 'puppeteer-core';
const CHROME = process.env.CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const [url, selector] = process.argv.slice(2);
const browser = await launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage();
const cdp = await page.createCDPSession();
await page.goto(url, { waitUntil: 'networkidle2' });
const { root } = await cdp.send('DOM.getDocument');
const { nodeId } = await cdp.send('DOM.querySelector', { nodeId: root.nodeId, selector });
const [n] = (await cdp.send('Accessibility.getPartialAXTree', { nodeId, fetchRelatives: false })).nodes;
console.log(`${selector}  role=${n.role.value}  name=${JSON.stringify(n.name.value)}`);
for (const s of n.name.sources) {
  const label = s.attribute ?? s.nativeSource ?? s.type;
  const value = s.value ? JSON.stringify(s.value.value) : '(absent)';
  console.log('  ' + label.padEnd(16), value, s.superseded ? '  superseded' : '');
}
await browser.close();
// find-text.mjs
import { launch } from 'puppeteer-core';
const CHROME = process.env.CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const browser = await launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage();
const cdp = await page.createCDPSession();
await page.goto(process.argv[2], { waitUntil: 'networkidle2' });
const { nodes } = await cdp.send('Accessibility.getFullAXTree');
const needle = process.argv[3];
const hits = nodes.filter((n) => (n.name?.value ?? '').includes(needle));
console.log(`"${needle}" in the DOM: ${await page.evaluate((s) => document.body.innerHTML.includes(s), needle)}`);
console.log(`"${needle}" in the tree: ${hits.length} node(s)`);
await browser.close();
// ax-count.mjs
import { launch } from 'puppeteer-core';
const CHROME = process.env.CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const browser = await launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage();
const cdp = await page.createCDPSession();
await page.goto(process.argv[2], { waitUntil: 'networkidle2' });
const dom = await page.evaluate(() => ({
  elements: document.querySelectorAll('*').length,
  img: document.querySelectorAll('img').length,
  svg: document.querySelectorAll('svg').length,
  button: document.querySelectorAll('button').length,
}));
const { nodes } = await cdp.send('Accessibility.getFullAXTree');
const role = (r) => nodes.filter((n) => n.role?.value === r);
console.log(`dom elements ${dom.elements}   ax nodes ${nodes.length}`);
console.log(`img ${dom.img}  svg ${dom.svg}  ->  ax image nodes ${role('image').length}`);
console.log(`button ${dom.button}  ->  ax button nodes ${role('button').length}`);
for (const n of role('image')) console.log('  image  ' + JSON.stringify(n.name?.value ?? ''));
for (const n of role('button')) console.log('  button ' + JSON.stringify(n.name?.value ?? ''));
await browser.close();

Steps

  1. Step 1.

    Print the tree Chrome computed. The script drops ignored nodes and text nodes, noise at this stage.

    node ax-tree.mjs http://127.0.0.1:8742/tree.html
    
    RootWebArea    "Order summary"  focusable=true
    heading        "Order summary"  level=1
    navigation     "Order steps"
    main           ""
    link           "Items"          focusable=true
    button         "Save order"     focusable=true
    button         "Close dialog"   focusable=true
    button         ""               focusable=true
    button         "Pay now"        disabled=true
    generic        ""

    Read each line as role, name, state. Four button elements produced four button nodes, and the third has no name. Pay now carries disabled=true and has lost focusable, so it is out of the tab order. The div with the click handler is generic: no role, no name, no focus.

  2. Step 2.

    Ask why the third button has no name. getPartialAXTree returns every source Chrome tried, in the order the accessible name computation defines.

    node ax-name.mjs http://127.0.0.1:8742/tree.html "#print"
    
    #print  role=button  name=""
    aria-labelledby  (absent)
    aria-label       (absent)
    label            (absent)
    contents         (absent)
    title            (absent)

    Five sources, five misses. contents is absent because the only child is an svg carrying aria-hidden="true", leaving no text to fall back to. One run gives both the accessible name and the reason it is empty.

  3. Step 3.

    Compare what the markup contains with what reached the tree.

    for t in "Prices include VAT." "Saved 2 minutes ago" "Cancel order"; do node find-text.mjs http://127.0.0.1:8742/tree.html "$t"; done
    
    "Prices include VAT." in the DOM: true
    "Prices include VAT." in the tree: 0 node(s)
    "Saved 2 minutes ago" in the DOM: true
    "Saved 2 minutes ago" in the tree: 0 node(s)
    "Cancel order" in the DOM: true
    "Cancel order" in the tree: 2 node(s)

    The paragraph marked aria-hidden="true" and the element with the hidden attribute are in the document and absent from the tree. Cancel order is there twice, as a text node and its inline box, never as a control. Text in the tree is not the same fact as a control.

  4. Step 4.

    Run the same reading against a page nobody wrote for this exercise.

    node ax-count.mjs https://developer.mozilla.org/en-US/docs/Web/HTTP
    
    dom elements 2000   ax nodes 1067
    img 0  svg 4  ->  ax image nodes 10
    button 9  ->  ax button nodes 7
    …
    button "Toggle navigation"
    button "Search the site"
    button "Toggle sidebar"
    button "Switch color theme"
    button "English (US)"
    button "Yes"
    button "No"

    2000 elements, 1067 tree nodes, and 9 button elements exposed as 7 buttons, each named for what it does. The image lines of this capture are read on How to check alt text on images.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | button "" | The control has no accessible name | Give it aria-label, or visually hidden text inside the button. Run step 2 to see which sources were tried. | | generic "" where a control belongs | A div or span is acting as a control | Replace it with button, or add a role, a name, tabindex and key handling. | | A name that is not the visible label | An ARIA attribute overrode the text | Compare the two. WCAG 2.5.3 requires the name to contain the visible text. | | Text in the DOM, absent from the tree | aria-hidden, hidden, or display: none | Confirm it is decoration. Status messages and error text hidden this way never reach anyone. | | disabled=true on a node with no focusable | The control is disabled, not just styled | Correct for a submit button. Wrong if the page relies on the user tabbing to it to read why. |

Common mistakes

Sign: The DevTools Elements tree and the accessibility tree are read as the same structure.Cause: They are two trees. On the MDN page captured above, 2000 DOM elements produced 1067 accessibility nodes, and 9 button elements produced 7 buttons. Counting elements in the inspector tells you nothing about what was exposed.
Sign: An element is declared accessible because its text is visible in the tree.Cause: The div in step 3 puts Cancel order into the tree as two text nodes, and it is still not a control. A tester who greps the tree dump for the label sees a hit. The role on that node is generic, it is not focusable, and no locator that asks for a button will find it.
Sign: An empty name is dismissed because the button is obviously an icon.Cause: The name is the only handle the control has. Voice control users say the name, screen reader users hear it, and getByRole('button', { name: 'Print' }) matches on it. Step 2 lists the five sources Chrome tried, so the fix is whichever line is cheapest to fill.

What to check next

FAQ

What is the accessibility tree?

A structure the browser computes from the DOM, CSS and ARIA and exposes to the platform accessibility API. Each node carries a role, a name and state. Assistive technology, voice control and role-based locators read it. Hidden and decorative elements are dropped.

How do I see the accessibility tree in Chrome?

Open DevTools, Elements panel, then the Accessibility pane. It shows the selected node's computed role and name, and the sources the name came from. Tick "Enable full-page accessibility tree" there for the whole document.

How do I test a website with a screen reader?

Install one and listen: NVDA on Windows, VoiceOver on macOS, TalkBack on Android. No screen reader produced any output on this page. A tree reading gives the input those tools work from, so an empty name or a wrong role is caught before anyone starts listening.

How do I check the accessible name of a button?

Select it in the Accessibility pane, or run step 2 with its selector. Both give the computed name and the ordered sources: aria-labelledby, aria-label, a native label, contents, then title. The first source with a value wins.

Verified

Verified by Maks VernyChrome 152.0.7977.76Node 22.23.2puppeteer-core 25.10.0

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate8 minpublished updated Maks Verny