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
- Chrome 120 or later, and Node 22.
npm i puppeteer-coreinstalls the driver only: it ships no browser and drives the Chrome already installed. SetCHROMEif yours is elsewhere. - The Accessibility domain of the DevTools protocol documents both commands used below.
- A page with the defects, served locally. Save these two files in one directory, run
node server.mjs 8742, and stop it by PID afterwards, never by image name.
// 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">×</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>
- Four scripts, in the same directory.
// 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();
- Every figure below is one capture on one machine, Chrome 152.0.7977.76, 2026-09-11. Node counts move between Chrome versions.
Steps
- 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.htmlRootWebArea "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
buttonelements produced four button nodes, and the third has no name.Pay nowcarriesdisabled=trueand has lostfocusable, so it is out of the tab order. Thedivwith the click handler isgeneric: no role, no name, no focus. - Step 2.
Ask why the third button has no name.
getPartialAXTreereturns 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.
contentsis absent because the only child is ansvgcarryingaria-hidden="true", leaving no text to fall back to. One run gives both the accessible name and the reason it is empty. - 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 thehiddenattribute 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. - 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/HTTPdom 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
buttonelements 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
What to check next
- How to check aria labels: which ARIA attribute became the name, and which lost.
- How to check alt text on images: the same reading for images, where a missing attribute and an empty one are opposites.
- How to check if a form is accessible: labels, grouping and error text, read from the tree.
- How to check landmark regions of a page: the
banner,mainandnavigationnodes above the controls. - How to run an axe accessibility test: a rule engine over the same data.
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.
Related on this site
intermediate8 minpublished updated Maks Verny