How to check link text for accessibility
Collect the accessible name of every link, not its text. Filter Accessibility.getFullAXTree to role=link, resolve each node back to its own element, and flag three things: an empty name, a bare URL as a name, and one name that points at several destinations. The demo page below fails all three.
Why check this
Run this on every list, table and card grid before release, and again after a component library replaces the link component with a wrapper. A screen reader user pulls up the list of links on the page and reads it out of context, so each name has to carry its own destination.
The failure it catches is a page of links that all read the same. Four cards, four Read more, four different articles. The reader gets a list with one entry repeated and no way to pick. An icon-only link is worse: it arrives with no name, so the entry is blank, and nothing the reader can say or type will address it.
Prerequisites
- Node 22 and Chrome on the same machine.
npm i puppeteer-coreinstalls the driver only: it ships no browser and drives the Chrome already installed. - The
ax.mjshelper from How to check heading structure of a page, saved next to the scripts below. - A page with the defects planted. This one has five: three links named
Read more, a bare URL as a name, an icon-only link with no name, a link named only bytitle, and two links whosearia-labeloverrides the visible text. Save it aslink-demo.mjs, on a port nothing else is using, and stop it afterwards by its PID.
// link-demo.mjs - a news list with five planted link-text defects. node link-demo.mjs
import { createServer } from 'node:http';
const page = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Link demo</title><style>
body{font:16px system-ui;margin:2rem;max-width:34rem} li{margin:.6rem 0}
.icon{display:inline-block;width:1em;height:1em;background:#0f766e}
</style></head><body>
<h1>Release notes</h1>
<ul>
<li>Billing rewrite. <a href="/notes/billing">Read more</a></li>
<li>New search. <a href="/notes/search">Read more</a></li>
<li>Faster exports. <a href="/notes/exports">Read more</a></li>
<li>Mirror: <a href="https://example.com/notes/exports">https://example.com/notes/exports</a></li>
<li>Archive <a href="/notes/archive"><span class="icon"></span></a></li>
<li><a href="/notes/legal" title="Legal notices"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACw=" width="16" height="16"></a></li>
<li><a href="/notes/pricing" aria-label="Pricing changes for 2027">See the table</a></li>
<li><a href="/notes/search">Read more</a> about the same search release</li>
<li><a href="/notes/search" aria-label="Search release, full notes">Details</a></li>
</ul>
</body></html>`;
createServer((_, res) => res.end(page)).listen(9155, () => console.log('http://localhost:9155/'));
- The figures come from one capture, on one machine, with Chrome 152.0.7977.76.
- WCAG 2.4.4 Link Purpose (In Context) is Level A, WCAG 2.4.9 Link Purpose (Link Only) is Level AAA, and WCAG 2.5.3 Label in Name is the Level A criterion behind the last flag.
Steps
- Step 1.
Start the demo server and leave it running in its own terminal.
node link-demo.mjshttp://localhost:9155/ - Step 2.
Collect every link name with its destination, then group the names to find the ones that are reused.
// links.mjs - node links.mjs <url> import { open } from './ax.mjs'; const target = process.argv[2]; const s = await open(target); try { const { nodes } = await s.cdp.send('Accessibility.getFullAXTree'); const links = []; for (const n of nodes) { if (n.role?.value !== 'link' || n.ignored) continue; // Resolve the AX node back to its own element. Looking it up by href finds // the first link with that href, which is a different element on any page // that links to the same place twice. const { object } = await s.cdp.send('DOM.resolveNode', { backendNodeId: n.backendDOMNodeId }); const { result } = await s.cdp.send('Runtime.callFunctionOn', { objectId: object.objectId, functionDeclaration: 'function () { return JSON.stringify({ href: this.getAttribute("href") || "", visible: (this.innerText || "").trim() }); }', returnByValue: true, }); const { href, visible } = JSON.parse(result.value); links.push({ name: n.name?.value ?? '', from: (n.name?.sources ?? []).find((x) => x.value?.value)?.type ?? '(nothing)', href, visible, }); } const base = new URL(target); const url = (h) => { try { const u = new URL(h, base); return u.origin === base.origin ? u.pathname + u.hash : u.href; } catch { return h; } }; console.log(` ${links.length} links`); for (const l of links) console.log(` name=${JSON.stringify(l.name).padEnd(30)} from=${String(l.from).padEnd(11)} href=${url(l.href)}`); console.log(' -- flags --'); const empty = links.filter((l) => !l.name.trim()); const bare = links.filter((l) => /^(https?:\/\/|www\.)\S+$/i.test(l.name.trim())); const mismatch = links.filter((l) => l.visible && l.name && !l.name.toLowerCase().includes(l.visible.toLowerCase())); const seen = {}; for (const l of links) (seen[l.name.trim().toLowerCase()] ??= new Set()).add(url(l.href)); console.log(` empty name: ${empty.map((l) => url(l.href)).join(', ') || '(none)'}`); console.log(` bare URL as name: ${bare.map((l) => l.name).join(', ') || '(none)'}`); console.log(` visible text not inside the name: ${mismatch.map((l) => `"${l.visible}" -> "${l.name}"`).join('; ') || '(none)'}`); for (const [n, set] of Object.entries(seen).filter(([n, set]) => n && set.size > 1)) console.log(` "${n}" names ${set.size} destinations: ${[...set].join(', ')}`); } finally { await s.close(); }node links.mjs http://localhost:9155/9 links name="Read more" from=contents href=/notes/billing name="Read more" from=contents href=/notes/search name="Read more" from=contents href=/notes/exports name="https://example.com/notes/exports" from=contents href=https://example.com/notes/exports name="" from=(nothing) href=/notes/archive name="Legal notices" from=attribute href=/notes/legal name="Pricing changes for 2027" from=attribute href=/notes/pricing name="Read more" from=contents href=/notes/search name="Search release, full notes" from=attribute href=/notes/search -- flags -- empty name: /notes/archive bare URL as name: https://example.com/notes/exports visible text not inside the name: "Details" -> "Search release, full notes" "read more" names 3 destinations: /notes/billing, /notes/search, /notes/exportsFour links are called
Read moreand the flag says three destinations, because two of them go to the same page. The count to act on is the number of destinations, not the number of links. - Step 3.
Copy
links.mjstolinks-byhref.mjsand replace the two CDP calls that resolve the node with a lookup by href, the shortcut most audit scripts take.const { node } = await s.cdp.send('DOM.describeNode', { backendNodeId: n.backendDOMNodeId }); const attrs = {}; for (let i = 0; i < (node.attributes ?? []).length; i += 2) attrs[node.attributes[i]] = node.attributes[i + 1]; const href = attrs.href ?? ''; const visible = await s.page.evaluate((h) => (document.querySelector(`a[href="${h}"]`)?.innerText ?? '').trim(), href);node links-byhref.mjs http://localhost:9155/… -- flags -- empty name: /notes/archive bare URL as name: https://example.com/notes/exports visible text not inside the name: "See the table" -> "Pricing changes for 2027"; "Read more" -> "Search release, full notes" "read more" names 3 destinations: /notes/billing, /notes/search, /notes/exportsThe last flag quotes
Read morewhere step 2 quotedDetails. Three links share that href, and the lookup returned the first of them every time. - Step 4.
Read where each name came from, and what it pushed aside.
// link-sources.mjs - node link-sources.mjs <url> import { open } from './ax.mjs'; const s = await open(process.argv[2]); try { const { nodes } = await s.cdp.send('Accessibility.getFullAXTree'); for (const n of nodes) { if (n.role?.value !== 'link' || n.ignored) continue; const sources = (n.name?.sources ?? []) .filter((x) => x.value?.value !== undefined) .map((x) => `${x.type}${x.attribute ? `[${x.attribute}]` : ''}=${JSON.stringify(x.value.value)}${x.superseded ? ' superseded' : ''}`); console.log(` ${JSON.stringify(n.name?.value ?? '').padEnd(32)} <- ${sources.join(' | ') || '(no source produced a name)'}`); } } finally { await s.close(); }node link-sources.mjs http://localhost:9155/"Read more" <- contents="Read more" "Read more" <- contents="Read more" "Read more" <- contents="Read more" "https://example.com/notes/exports" <- contents="https://example.com/notes/exports" "" <- (no source produced a name) "Legal notices" <- attribute[title]="Legal notices" "Pricing changes for 2027" <- attribute[aria-label]="Pricing changes for 2027" | contents="See the table" superseded "Read more" <- contents="Read more" "Search release, full notes" <- attribute[aria-label]="Search release, full notes" | contents="Details" supersededThe empty link produced no source at all: the image inside it has no alternative and the
spanholds no text. Two links carrysuperseded, which is Chrome saying the visible words lost to an attribute. - Step 5.
Run the same audit against a real site, where the reused names are the only finding.
node links.mjs https://developer.mozilla.org/en-US/docs/Web/HTTP143 links name="Scrimba" from=contents href=https://scrimba.com/learn/frontend?via=mdn name="Auth0" from=contents href=/pong/click name="AD" from=contents href=/en-US/advertising name="MDN logo" from=attribute href=/ name="Mozilla logo" from=attribute href=https://www.mozilla.org/ … -- flags -- empty name: (none) bare URL as name: (none) visible text not inside the name: (none) "guides" names 3 destinations: /en-US/docs/Web/HTTP#guides, /en-US/docs/Web/HTTP/Guides, /en-US/docs/MDN/Guides "reference" names 2 destinations: /en-US/docs/Web/HTTP#reference, /en-US/docs/Web/HTTP/ReferenceNo empty names in 143 links, and two names still point at more than one place. Captured on 2026-09-11. The link count moves between loads, because the advertisement at the top of the page is not always there.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| name="" from=(nothing) | Nothing gives the link a name | Add aria-label, or give the image inside it an alternative |
| A bare URL as the name | The URL is read out character group by character group | Replace it with the title of the page it goes to |
| One name, several destinations | The list of links has one entry repeated | Name each link after its destination |
| superseded in the source chain | An attribute replaced the visible words | Make sure the name starts with the visible text, or 2.5.3 fails |
| attribute[title] as the only source | The name comes from a tooltip | Move it to aria-label, since title shows on hover and nowhere else |
| One name, one destination, repeated | Two links to the same place | Not a defect. Count destinations, not links |
Common mistakes
What to check next
- How to check accessibility tree: the full tree this check filters down to the links.
- How to check alt text on images: why the icon link in step 4 produced no name source at all.
- How to check aria labels: the attribute that supplied four of the nine names, and how it supersedes the text.
- How to check heading structure of a page: the other list a screen reader user reads out of context.
FAQ
How do I make a link accessible?
Give it text that names its destination, and keep that text inside the accessible name. An icon-only link needs aria-label or an image alternative. Step 2 flags the three cases that fail: empty, a bare URL, and a name shared across destinations.
How do I fix ambiguous link text?
Replace Read more with the title of the thing it opens. When the layout has no room, extend the name rather than replacing it: put the visible words first inside aria-label, so Read more about billing still matches what a voice control user says.
Is a repeated link name always a defect?
No. Two links to the same page may share a name, and the check in step 2 counts destinations for that reason. On the MDN capture guides names three different pages, which is the case worth a ticket.
Does a title attribute count as link text?
It supplies a name, as step 4 shows for the legal notices link, and it is the last source Chrome tries. It appears on hover only, so it reaches neither a touch user nor a keyboard user. Use aria-label instead.
Can I check this from the markup?
Partly. A parser finds empty anchors and bare URLs. It cannot resolve aria-labelledby, it will not tell you which source won, and it cannot report superseded, which is where the 2.5.3 failures are.
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
intermediate10 minpublished updated Maks Verny