Accessible Names
The accessible name is the string a screen reader announces for an element. It's what your user actually hears — and it's almost never just the element's text content. This page explains how that name is computed, what contributes, and what wins when multiple sources compete.
Every SemanticNode.a11y.name in a Real A11y tree comes from this algorithm.
The priority order
For any element, the accessible name is picked from the first non-empty source in this list:
aria-labelledby— references another element's text contentaria-label— literal string on the element itself- Native host-language label:
<label for="…">or ancestor<label>for form controls<fieldset><legend>for grouped controls<caption>for tablesaltfor images,titlefor iframes
- Text content — inner text, including text inside descendants. Only for roles named from their content (buttons, links, headings, cells, options…): a dialog, image, landmark or text field is named by its author alone, never by its text
titleattribute — last-resort fallback- Empty string (element has no accessible name)
If #1 resolves to a non-empty string, #2–#5 are ignored — even if #2 looks "more specific" from a CSS or developer perspective.
One thing a tree shows beyond this list. A paragraph, a list item, or a live region (alert, status) with text directly inside it shows that text as its name: paragraph "Read the guide", status "3 results". That keeps its content visible in a snapshot, where the tree prints names and nothing else. A screen reader reads that text as the element's content, not as a label. It never applies to a dialog, image, landmark or text field.
Concrete examples
Button with only text
<button>Save changes</button>→ "Save changes" (from #4, text content).
Button with an icon and hidden text
<button>
<svg aria-hidden="true">…</svg>
<span class="sr-only">Close dialog</span>
</button>→ "Close dialog" (from #4). The aria-hidden SVG contributes nothing; the visually-hidden <span> does because screen readers read it.
Button with only an icon
<button>
<svg aria-hidden="true">…</svg>
</button>→ "" — no name. This fails assertNoUnlabeledInteractive(). Fix with aria-label:
<button aria-label="Close dialog">
<svg aria-hidden="true">…</svg>
</button>Dialog with text but no label
<div role="dialog">
Delete this project?
<button>Cancel</button>
</div>→ "" — no name. A dialog's text is its content, not its label: a screen reader announces this dialog unnamed. The same holds for an image, a landmark (<nav>, <aside>, <footer>…) and a text field, whose text is the user's value. (An unnamed <form> is not a landmark at all — it is a generic, and a generic does take its loose text.) This fails assertDialogsLabeled(). Fix it by pointing aria-labelledby at a visible heading:
<div role="dialog" aria-labelledby="delete-title">
<h2 id="delete-title">Delete this project?</h2>
<button>Cancel</button>
</div>Form control with a wrapping label
<label>
Email
<input type="email" />
</label>→ input's name is "Email" (from #3 — native label association). Real A11y's label handling prunes the label text from the tree so it doesn't surface as a redundant generic "Email" sibling.
Form control with for / id
<label for="q">Search</label>
<input id="q" type="search" />→ input's name is "Search" (same path).
aria-label beats text content
<a href="/home" aria-label="Go to homepage">Home</a>→ "Go to homepage" (from #2). The visible text "Home" is ignored by screen readers here — a classic footgun if the two drift apart.
aria-labelledby beats everything
<h2 id="section-title">Pricing plans</h2>
<section aria-labelledby="section-title" aria-label="ignored">
…
</section>→ region's name is "Pricing plans" (from #1). The aria-label is ignored.
aria-labelledby can reference multiple IDs — their text content is concatenated with spaces:
<label id="first">First name</label>
<span id="req">(required)</span>
<input aria-labelledby="first req" />→ input's name is "First name (required)".
What does not contribute
placeholder— by the spec, placeholders are a fallback name only if nothing else is available, and many screen readers ignore them entirely. Don't rely on placeholders as labels.title— the last fallback. It surfaces as a tooltip in desktop browsers, but many AT users (mobile, voice control) never see it. Treat it as documentation, not a label.- Text inside
aria-hidden="true"subtrees — excluded from name computation. As in Chromium, any value butfalse, an empty value orundefinedhides, in any case:aria-hidden="TRUE"andaria-hidden="yes"hide too. - CSS-generated content (
::before,::aftercontent) — spec says it should contribute, but engine support is inconsistent. Don't rely on it for critical labels. <img alt="">with empty alt — the image is treated as decorative and contributes no name. Unless it also has atitle: HTML-AAM keeps such an image in the tree and names it from the title, so<img alt="" title="Company logo">is animgnamed"Company logo".
Debugging an unexpected name
When treeSnapshot() shows a name you didn't expect:
- Check for
aria-label/aria-labelledbyon the element or an ancestor — #1 and #2 override everything else. - Follow the
aria-labelledbychain. The referenced IDs might be missing, hidden, or pointing at the wrong element. - Look for visually-hidden text inside the element —
.sr-only,.visually-hidden. Often intentional, sometimes accidental. - Check
aria-hiddenon ancestors — a hidden wrapper removes the element's subtree from name computation. - Compare against the Chrome extension. If the extension shows the same name, it's what real AT will announce.
Spacing between a name's parts
When a name is built from an element's content, each child that renders as a box of its own is separated with a space, and a child that flows inline is not — the same rule Chromium applies. So <button><div>Save</div><div>now</div></button> is "Save now", while <button><span>Sa</span><span>ve</span></button> is one word, "Save". A child that lends no text still separates the text around it if it renders: <h1>Save<input>now</h1> is "Save now".
Because the rule reads computed display, the same markup can produce a different name in jsdom than in a real browser wherever jsdom's CSS engine differs from a browser's. jsdom does not blockify flex or grid items, floats or absolutely positioned children, and it treats <select> and <textarea> as inline where a browser makes them inline-block. Those cases read as one word under a jsdom-based matcher and spaced in a browser. If a name looks glued only in jsdom, check the child's computed display before treating it as a bug — and prefer the Playwright path, which runs in a real browser, when the distinction matters to your assertion.
Why this matters
The accessible name is the only piece of text a screen reader user has to distinguish one element from another. Two buttons labeled "Edit" in a list of items are indistinguishable — not because the user is confused, but because from their perspective there is no more information.
Real A11y's assertions (assertNoUnlabeledInteractive, assertDialogsLabeled) and snapshots (treeSnapshot) all surface the accessible name as the primary identifier — because that's what the user hears, and that's what your tests should lock in.