How to check aria labels
Never read aria-label from the markup. Read the computed name it produced, and the list of sources the browser tried. On the test page below, seven elements carry an ARIA label, six of the names came from one, one attribute pointed at an id that does not exist, and one lost to aria-labelledby on the same element.
Why check this
Run this after any change to a component library, an icon set or a translation file, before staging sign-off. An aria-label is invisible in the rendered page, so looking at the screen catches nothing, and a stale one survives every visual review.
Two failures make this worth an explicit pass. The first is a label that does not apply: written on an element that cannot be named, or pointing at an id that was renamed. The second is a label that applies and contradicts the visible text. A button reading Submit with aria-label="Send form" fails WCAG 2.5.3 Label in Name, and a voice control user who says the word on screen gets nothing.
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 accessible name computation fixes the order of sources. WAI-ARIA 1.2 lists the roles an author cannot name at all.
- A local page with the cases on it. Save it next to the
server.mjsfrom How to check accessibility tree, runnode server.mjs 8742, and stop it by PID afterwards, never by image name.
<!-- aria.html -->
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Support form</title></head>
<body>
<h1>Support form</h1>
<div aria-label="Ticket filters">Filters</div>
<button aria-labelledby="missing-id">Attach file</button>
<button aria-label="Send form">Submit</button>
<button aria-label="Delete" aria-labelledby="row-name">x</button>
<span id="row-name">Invoice 4471</span>
<button aria-labelledby="tooltip">Export</button>
<span id="tooltip" style="display:none">Export as CSV</span>
<input id="email" type="email" aria-label="Email address">
<label for="email">Work email</label>
<button><span aria-label="Open menu">☰</span></button>
</body>
</html>
- Three scripts.
ax-name.mjscomes from the tree procedure linked above. These two are new.
// aria-audit.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: '[aria-label], [aria-labelledby]',
});
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 [n] = (await cdp.send('Accessibility.getPartialAXTree', { nodeId, fetchRelatives: false })).nodes;
const won = (n.name?.sources ?? []).find((s) => s.value && !s.superseded);
const attr = a['aria-labelledby'] ? `aria-labelledby="${a['aria-labelledby']}"` : `aria-label="${a['aria-label']}"`;
console.log(
`<${node.nodeName.toLowerCase()}> ${attr}`.padEnd(46),
`role=${n.role?.value}`.padEnd(15),
`name=${JSON.stringify(n.name?.value ?? '')}`.padEnd(22),
`from ${won ? (won.attribute ?? won.nativeSource ?? won.type) : 'nothing'}`
);
}
await browser.close();
// label-in-name.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: 'button, a[href], input, select, textarea',
});
const norm = (s) => s.toLowerCase().replace(/\s+/g, ' ').trim();
for (const nodeId of nodeIds) {
const { object } = await cdp.send('DOM.resolveNode', { nodeId });
const visible = (await cdp.send('Runtime.callFunctionOn', {
objectId: object.objectId,
functionDeclaration: 'function(){return (this.innerText||this.labels?.[0]?.innerText||"").trim()}',
returnByValue: true,
})).result.value;
if (!visible) continue;
const [n] = (await cdp.send('Accessibility.getPartialAXTree', { nodeId, fetchRelatives: false })).nodes;
const name = n.name?.value ?? '';
const ok = norm(name).includes(norm(visible));
console.log(`${ok ? 'ok ' : 'FAIL'} visible=${JSON.stringify(visible)}`.padEnd(34), `name=${JSON.stringify(name)}`);
}
await browser.close();
- Every figure below is one capture on one machine, Chrome 152.0.7977.76, 2026-09-11.
Steps
- Step 1.
List every element carrying an ARIA label, with the role and name it ended up with, and the source that supplied the name.
node aria-audit.mjs http://127.0.0.1:8742/aria.html<div> aria-label="Ticket filters" role=generic name="Ticket filters" from aria-label <button> aria-labelledby="missing-id" role=button name="Attach file" from contents <button> aria-label="Send form" role=button name="Send form" from aria-label <button> aria-labelledby="row-name" role=button name="Invoice 4471" from aria-labelledby <button> aria-labelledby="tooltip" role=button name="Export as CSV" from aria-labelledby <input> aria-label="Email address" role=textbox name="Email address" from aria-label <span> aria-label="Open menu" role=generic name="Open menu" from aria-labelThe last column is the finding. Line 2 reads
from contents: the reference points at an id that is not on the page, Chrome fell through to the button text, and the name is right by accident. Lines 1 and 7 landed on rolegeneric, which WAI-ARIA lists among the roles where naming is prohibited, so those labels sit on a node with no role to carry them. - Step 2.
Take the button carrying both attributes and read the order.
node ax-name.mjs http://127.0.0.1:8742/aria.html "button[aria-label=Delete]"button[aria-label=Delete] role=button name="Invoice 4471" aria-labelledby "Invoice 4471" aria-label "Delete" superseded label (absent) superseded contents "x" superseded title (absent) supersededaria-labelledbywins and everything below it is markedsuperseded. The author wrote Delete and shipped a button named after the row. Five lines give the precedence, the losing values and the visible content. - Step 3.
Check a field with both a real label and an ARIA label.
node ax-name.mjs http://127.0.0.1:8742/aria.html "#email"#email role=textbox name="Email address" aria-labelledby (absent) aria-label "Email address" labelfor "Work email" superseded title (absent) superseded placeholder (absent) superseded aria-placeholder (absent) superseded title (absent) supersededThe
<label for="email">is still on screen, still clickable, and no longer the name. The field reads Work email and answers to Email address. Deleting thearia-labelis the fix, not editing it: the native label was already correct. - Step 4.
Compare each control's visible text with its computed name, as WCAG 2.5.3 asks.
node label-in-name.mjs http://127.0.0.1:8742/aria.htmlok visible="Attach file" name="Attach file" FAIL visible="Submit" name="Send form" FAIL visible="x" name="Invoice 4471" ok visible="Export" name="Export as CSV" FAIL visible="Work email" name="Email address" FAIL visible="☰" name="Open menu"Three of the four failures are real. The fourth, the hamburger glyph, is a false positive: a character nobody reads aloud is not a visible label, so the rule does not apply. Filter icon glyphs out before putting this in a pipeline, or the report trains people to ignore it.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| from contents on an element with aria-labelledby | The referenced id is missing or unreachable | Fix the id. The current name is whatever the element happened to contain. |
| from aria-labelledby while aria-label is superseded | Both attributes are present and one is dead code | Delete the losing attribute so the next reader is not misled. |
| role=generic with a name | The label is on a div or span that cannot be named | Move it to the element with the role, or give the element a role. |
| A name that does not contain the visible text | WCAG 2.5.3 Label in Name failure | Start the name with the visible text, or drop the ARIA label. |
| labelfor marked superseded | An aria-label is overriding a real <label> | Usually the aria-label is the mistake. The native label is visible and clickable. |
Common mistakes
What to check next
- How to check accessibility tree: where these names live, and the role and state beside them.
- How to check alt text on images: naming for images, where an empty value means decorative.
- How to test aria attributes: the state and relationship attributes,
aria-expandedand the rest. - How to check if a form is accessible: the field from step 3 with grouping and error text.
- How to check link text for accessibility: the same comparison for links, where generic text is the usual failure.
FAQ
What is the difference between aria-label and aria-labelledby?
aria-label carries the text itself. aria-labelledby carries a list of element ids and takes their text. aria-labelledby is computed first and supersedes aria-label, as step 2 shows. Use aria-labelledby when the label already exists on screen, so the two cannot drift apart.
How do I test aria labels without a screen reader?
Read the computed name, which is what a screen reader reads. Steps 1 to 3 produce it for every labelled element, with the sources that were tried and the ones that lost. No screen reader produced any output on this page.
How do I check an aria label in Chrome DevTools?
Open DevTools, Elements panel, select the element, then the Accessibility pane. The Computed Properties section shows the name, and the name sources are listed in the order Chrome tried them, with the winner at the top.
Should every button have an aria-label?
No. A button with visible text is already named by its text, and adding a label creates a second name that can drift. Label the controls that have no text, mostly icon buttons, and leave the rest alone.
Does aria-label work on any element?
No. Roles such as generic, which is what a plain div or span gets, are listed in WAI-ARIA as prohibited from author naming. Put the label on the element that carries the role, or give that element a role first.
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