How to test aria attributes
Read the state the browser exposed, not the attribute you wrote. Call Accessibility.getPartialAXTree on every element carrying an aria-* attribute and compare the two lists. On the test page below, aria-checked="yes" arrived in the tree as checked="true", and a misspelled attribute produced no state at all.
Why check this
Run this after a change to a custom widget or a design-system component, before staging sign-off. An ARIA state attribute fails in silence: nothing renders differently, the console says nothing about it, and the build passes.
The failure it prevents is a filter panel whose checkboxes are written aria-checked="yes". Every one reports checked to the platform, so all filters read as selected when none are, and the state never flips.
What this procedure decides
It decides whether each attribute reached the accessibility tree, and with what value, which is what Chrome hands to every assistive technology on the machine. It does not decide whether that state is the right one: a button exposing expanded=false while its menu is open is a logic defect this reading cannot see.
Prerequisites
- Chrome 120 or later and Node 22.
npm i puppeteer-coreinstalls the driver only: it drives the Chrome already installed. SetCHROMEif yours is elsewhere. - WAI-ARIA 1.2 section 9.2 defines what a user agent does with an unknown value, and what it substitutes for a missing required one.
- A page carrying the defects, served locally. Save it beside the
server.mjsfrom How to check accessibility tree, runnode server.mjs 8756, and stop that PID afterwards, never the image name.
<!-- attrs.html -->
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Notification settings</title></head>
<body>
<h1>Notification settings</h1>
<div id="ok-checkbox" role="checkbox" tabindex="0" aria-checked="true">Email alerts</div>
<div id="bad-value" role="checkbox" tabindex="0" aria-checked="yes">SMS alerts</div>
<div id="no-state" role="checkbox" tabindex="0">Push alerts</div>
<div id="slider" role="slider" tabindex="0" aria-label="Retry delay"
aria-valuemin="0" aria-valuemax="60"></div>
<button id="typo" aria-hasspopup="menu" aria-expanded="false">Filters</button>
<button id="not-allowed" aria-selected="true">Save view</button>
<button id="bad-expanded" aria-expanded="collapsed">Details</button>
<button id="tristate" aria-pressed="mixed">Bold</button>
<input id="upper" type="text" aria-label="Search" aria-required="TRUE">
</body>
</html>
- A second page, for step 3, with one attribute at nine values.
<!-- values.html -->
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>value probe</title></head><body>
<div id="v-true" role="checkbox" tabindex="0" aria-checked="true">a</div>
<div id="v-false" role="checkbox" tabindex="0" aria-checked="false">b</div>
<div id="v-yes" role="checkbox" tabindex="0" aria-checked="yes">c</div>
<div id="v-no" role="checkbox" tabindex="0" aria-checked="no">d</div>
<div id="v-0" role="checkbox" tabindex="0" aria-checked="0">e</div>
<div id="v-1" role="checkbox" tabindex="0" aria-checked="1">f</div>
<div id="v-FALSE" role="checkbox" tabindex="0" aria-checked="FALSE">g</div>
<div id="v-empty" role="checkbox" tabindex="0" aria-checked="">h</div>
<div id="v-mixed" role="checkbox" tabindex="0" aria-checked="mixed">i</div>
</body></html>
- Three scripts in the same directory.
// aria-attrs.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 { root } = await cdp.send('DOM.getDocument', { depth: -1 });
const { nodeIds } = await cdp.send('DOM.querySelectorAll', { nodeId: root.nodeId, selector: '[id]' });
for (const nodeId of nodeIds) {
const { node } = await cdp.send('DOM.describeNode', { nodeId });
const a = {};
for (let i = 0; i < node.attributes.length; i += 2) a[node.attributes[i]] = node.attributes[i + 1];
const written = Object.keys(a).filter((k) => k.startsWith('aria-') && k !== 'aria-label');
const [n] = (await cdp.send('Accessibility.getPartialAXTree', { nodeId, fetchRelatives: false })).nodes;
const props = (n.properties ?? [])
.filter((p) => !['focusable', 'invalid', 'settable', 'editable', 'multiline', 'readonly'].includes(p.name))
.map((p) => `${p.name}=${JSON.stringify(p.value.value)}`);
if (n.value) props.unshift(`value=${JSON.stringify(n.value.value)}`);
console.log('#' + a.id);
console.log(' written ' + (written.map((k) => `${k}="${a[k]}"`).join(' ') || '(no state attribute)'));
console.log(' exposed role=' + (n.role?.value ?? '(none)') + ' ' + (props.join(' ') || '(no state)'));
}
await browser.close();
// aria-diff.mjs
import { launch } from 'puppeteer-core';
// The 48 states and properties defined by WAI-ARIA 1.2. Anything else is a typo.
const ARIA12 = new Set(('activedescendant atomic autocomplete busy checked colcount colindex colspan ' +
'controls current describedby details disabled dropeffect errormessage expanded flowto grabbed ' +
'haspopup hidden invalid keyshortcuts label labelledby level live modal multiline multiselectable ' +
'orientation owns placeholder posinset pressed readonly relevant required roledescription rowcount ' +
'rowindex rowspan selected setsize sort valuemax valuemin valuenow valuetext').split(' ').map((s) => 'aria-' + s));
// Attributes whose result is not a property of the same name.
const MAPPED = { 'aria-label': 'name', 'aria-labelledby': 'name', 'aria-describedby': 'description', 'aria-valuenow': 'value' };
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 { root } = await cdp.send('DOM.getDocument', { depth: -1 });
const { nodeIds } = await cdp.send('DOM.querySelectorAll', { nodeId: root.nodeId, selector: '*' });
for (const nodeId of nodeIds) {
const { node } = await cdp.send('DOM.describeNode', { nodeId });
const a = {};
for (let i = 0; i < node.attributes.length; i += 2) a[node.attributes[i]] = node.attributes[i + 1];
const written = Object.keys(a).filter((k) => k.startsWith('aria-'));
if (!written.length) continue;
const [n] = (await cdp.send('Accessibility.getPartialAXTree', { nodeId, fetchRelatives: false })).nodes;
const props = Object.fromEntries((n.properties ?? []).map((p) => [p.name, p.value.value]));
if (n.value) props.value = n.value.value;
if (n.name) props.name = n.name.value;
for (const attr of written) {
const where = `#${a.id ?? node.nodeName.toLowerCase()}`.padEnd(14) + `${attr}="${a[attr]}"`.padEnd(30);
if (!ARIA12.has(attr)) { console.log(where + 'NOT A WAI-ARIA 1.2 ATTRIBUTE'); continue; }
const key = MAPPED[attr] ?? attr.slice(5);
if (!(key in props)) console.log(where + `DROPPED, role=${n.role?.value} exposes no ${key}`);
else console.log(where + `ok, ${key}=${JSON.stringify(props[key])}`);
}
}
await browser.close();
// required.mjs
import { launch } from 'puppeteer-core';
// Required states and properties, from the role definitions in WAI-ARIA 1.2.
const REQUIRED = { checkbox: ['aria-checked'], radio: ['aria-checked'], switch: ['aria-checked'],
slider: ['aria-valuenow'], scrollbar: ['aria-controls', 'aria-valuenow'],
combobox: ['aria-expanded'], heading: ['aria-level'] };
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 { root } = await cdp.send('DOM.getDocument', { depth: -1 });
const { nodeIds } = await cdp.send('DOM.querySelectorAll', { nodeId: root.nodeId, selector: '[role]' });
for (const nodeId of nodeIds) {
const { node } = await cdp.send('DOM.describeNode', { nodeId });
const a = {};
for (let i = 0; i < node.attributes.length; i += 2) a[node.attributes[i]] = node.attributes[i + 1];
const need = REQUIRED[a.role];
if (!need) continue;
const [n] = (await cdp.send('Accessibility.getPartialAXTree', { nodeId, fetchRelatives: false })).nodes;
const props = Object.fromEntries((n.properties ?? []).map((p) => [p.name, p.value.value]));
if (n.value) props.value = n.value.value;
for (const attr of need) {
const key = attr === 'aria-valuenow' ? 'value' : attr.slice(5);
const shown = key in props ? `${key}=${JSON.stringify(props[key])}` : `no ${key}`;
console.log(`#${a.id}`.padEnd(14) + `role=${a.role}`.padEnd(16) +
`${attr} ${attr in a ? 'written' : 'MISSING'}`.padEnd(26) + `tree says ${shown}`);
}
}
await browser.close();
- Every figure below is one capture on one machine, Chrome 152.0.7977.76, 2026-09-11.
Steps
- Step 1.
Put what the markup says next to what the tree says.
node aria-attrs.mjs http://127.0.0.1:8756/attrs.html#ok-checkbox written aria-checked="true" exposed role=checkbox checked="true" #bad-value written aria-checked="yes" exposed role=checkbox checked="true" #no-state written (no state attribute) exposed role=checkbox checked="false" #slider written aria-valuemin="0" aria-valuemax="60" exposed role=slider value=30 orientation="horizontal" valuemin=0 valuemax=60 valuetext="" #typo written aria-hasspopup="menu" aria-expanded="false" exposed role=button expanded=false #not-allowed written aria-selected="true" exposed role=button (no state) #bad-expanded written aria-expanded="collapsed" exposed role=button expanded=true #tristate written aria-pressed="mixed" exposed role=button pressed="mixed" #upper written aria-required="TRUE" exposed role=textbox required=trueFour lines are defects and none looks like one.
#bad-valuewroteyesand gotchecked="true".#bad-expandedwrotecollapsedand gotexpanded=true, the opposite of the word.#typoand#not-allowedwrote an attribute that produced nothing.#no-stateand#slidercarry a state nobody wrote. - Step 2.
Turn that comparison into a verdict per attribute.
node aria-diff.mjs http://127.0.0.1:8756/attrs.html#ok-checkbox aria-checked="true" ok, checked="true" #bad-value aria-checked="yes" ok, checked="true" #slider aria-label="Retry delay" ok, name="Retry delay" #slider aria-valuemin="0" ok, valuemin=0 #slider aria-valuemax="60" ok, valuemax=60 #typo aria-hasspopup="menu" NOT A WAI-ARIA 1.2 ATTRIBUTE #typo aria-expanded="false" ok, expanded=false #not-allowed aria-selected="true" DROPPED, role=button exposes no selected #bad-expanded aria-expanded="collapsed" ok, expanded=true #tristate aria-pressed="mixed" ok, pressed="mixed" #upper aria-label="Search" ok, name="Search" #upper aria-required="TRUE" ok, required=trueTwo kinds of silence separate here.
aria-hasspopupis not an ARIA attribute at all, so tree reading never finds it: the name list in the script is what catches it.aria-selectedis a real attribute on a role that does not support it, so it reached the browser and was dropped.#bad-valueand#bad-expandedare reportedok, because they did produce a state. - Step 3.
Find out which values the browser accepts, using nine of them on one attribute.
node aria-attrs.mjs http://127.0.0.1:8756/values.html#v-true written aria-checked="true" exposed role=checkbox checked="true" #v-false written aria-checked="false" exposed role=checkbox checked="false" #v-yes written aria-checked="yes" exposed role=checkbox checked="true" #v-no written aria-checked="no" exposed role=checkbox checked="true" #v-0 written aria-checked="0" exposed role=checkbox checked="true" #v-1 written aria-checked="1" exposed role=checkbox checked="true" #v-FALSE written aria-checked="FALSE" exposed role=checkbox checked="false" #v-empty written aria-checked="" exposed role=checkbox checked="false" #v-mixed written aria-checked="mixed" exposed role=checkbox checked="mixed"noreads as checked.0reads as checked. Onlyfalsein any case, the empty string, andmixedproduce anything else. WAI-ARIA 1.2 section 9.2 requires this: an unknown value exposed as a platform boolean counts as false only when empty or absent, and "any other value as true". A wrong value is not ignored, it is on. - Step 4.
List the states each role requires, and see what the browser used when one was absent.
node required.mjs http://127.0.0.1:8756/attrs.html#ok-checkbox role=checkbox aria-checked written tree says checked="true" #bad-value role=checkbox aria-checked written tree says checked="true" #no-state role=checkbox aria-checked MISSING tree says checked="false" #slider role=slider aria-valuenow MISSING tree says value=30Neither missing attribute is reported as missing. The checkbox reads
checked="false", identical to one written that way, and the slider reads a value the page never contained. Chrome applied the fallback table from WAI-ARIA 1.2, so the gap shows only in theMISSINGcolumn, which comes from the markup.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| written aria-checked="yes", exposed checked="true" | The value is not in the allowed set and was treated as true | Write true, false or mixed. Any other string turns the state on. |
| NOT A WAI-ARIA 1.2 ATTRIBUTE | The attribute name is misspelled or invented | Fix the spelling. Nothing in the browser reports this, and the tree looks the same with or without it. |
| DROPPED, role=X exposes no Y | A real attribute the element's role does not support | Move it to the element that has the role, or change the role. |
| MISSING with a state still in the tree | The browser substituted a fallback value | Write the attribute. The current state is a repair, not your intent. |
| exposed role=button (no state) | Nothing this element declares reached the tree | Read step 2 to find out which attribute was dropped and why. |
Thresholds
Common mistakes
What to check next
- How to check accessibility tree: where these states live, beside role and name.
- How to check aria labels: the naming attributes, which follow different rules.
- How to test aria live regions:
aria-live, where timing decides the result. - How to check if a form is accessible:
aria-requiredandaria-invalidon real fields. - How to test keyboard navigation on a website: the keys a
divwithrole="checkbox"still owes.
FAQ
How do I check aria roles?
Read the role line in step 1. An unknown role name is discarded and the element falls back to its native role, so <table role="foo"> is exposed as a table. Comparing written role against computed role finds the discarded ones.
Which aria attributes matter for accessibility?
The ones the element's role requires, plus any state that changes at runtime. Step 4 lists the required set per role. An attribute that never moves while the widget does is the usual defect, and a single capture reads it as correct.
Does an invalid aria value break the page?
Nothing breaks. The page renders, the console carries no ARIA message, and the DevTools Issues panel listed nothing for either test page. The element carries a state the author did not write, which is why this check compares the two lists.
Is aria-checked case sensitive?
Not in Chrome. FALSE was exposed as checked="false" in step 3. Relying on that is still a defect: the tokens in WAI-ARIA 1.2 are lower case, and another engine may treat FALSE as unknown, which turns the state on.
Can I test aria attributes without a screen reader?
Yes, and no screen reader produced any output on this page. Every figure above is a state Chrome handed to the platform accessibility API, so an attribute missing there is missing for every assistive technology.
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
intermediate9 minpublished updated Maks Verny