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

<!-- 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">&#9776;</span></button>
</body>
</html>
// 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();

Steps

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

    The 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 role generic, which WAI-ARIA lists among the roles where naming is prohibited, so those labels sit on a node with no role to carry them.

  2. 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)   superseded

    aria-labelledby wins and everything below it is marked superseded. The author wrote Delete and shipped a button named after the row. Five lines give the precedence, the losing values and the visible content.

  3. 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)   superseded

    The <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 the aria-label is the fix, not editing it: the native label was already correct.

  4. 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.html
    
    ok    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

Sign: An aria-labelledby that points at a missing id is reported as passing.Cause: Nothing errors and nothing renders differently. Chrome falls through to the next source, and in step 1 the button ended up named Attach file from its own text. The defect only appears when the fallback is empty, which is usually on the one icon button that has no text.
Sign: An aria-label on a div is counted as a label.Cause: Both the div and the span in step 1 came back as role generic with a name attached. WAI-ARIA lists generic among the roles where author naming is prohibited, so that name has no role to attach to. The attribute is in the markup, the tree shows it, and it does nothing.
Sign: aria-labelledby is assumed not to work when the target is hidden.Cause: The Export button in step 1 is named from a span with display:none, and the name is Export as CSV. Reference resolution ignores the visibility of the target. That is useful for a hidden description and dangerous for a template that hides a stale string.
Sign: An aria-label is added on top of a working label to make a test locator match.Cause: Step 3 shows the native label marked superseded. The locator now passes, the field is announced as Email address, and the word on screen is Work email. Two names for one field is a defect that a green pipeline created.

What to check next

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.

intermediate8 minpublished updated Maks Verny