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

// 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/'));

Steps

  1. Step 1.

    Start the demo server and leave it running in its own terminal.

    node link-demo.mjs
    
    http://localhost:9155/
  2. 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/exports

    Four links are called Read more and 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.

  3. Step 3.

    Copy links.mjs to links-byhref.mjs and 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/exports

    The last flag quotes Read more where step 2 quoted Details. Three links share that href, and the lookup returned the first of them every time.

  4. 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" superseded

    The empty link produced no source at all: the image inside it has no alternative and the span holds no text. Two links carry superseded, which is Chrome saying the visible words lost to an attribute.

  5. 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/HTTP
    
      143 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/Reference

    No 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

Sign: An audit reports a link whose visible text does not match its name, and the quoted text belongs to a different link.Cause: Matching an accessibility node back to the DOM with document.querySelector('a[href=...]') returns the first link with that href. On the demo page three links share /notes/search, so the naive variant in step 3 blamed Read more for a mismatch that belongs to Details. Resolve the node by its backendNodeId, as step 2 does, and the quotation is the right one.
Sign: Every link passes a per-element check, and the page still fails 2.4.4.Cause: Read more is a valid name. Three of them pointing at three articles are not, and no check that looks at one element at a time can see that. The defect only appears when the names are grouped across the page. Group by name and count distinct destinations: two links to the same page share a name legitimately, and the flag has to allow it.
Sign: A link is given an aria-label that reads better than the words on screen, and voice control stops working on it.Cause: The label replaces the visible text rather than extending it. Step 4 shows contents=See the table marked superseded by aria-label=Pricing changes for 2027. A user who says click see the table addresses a name that no longer contains those words. WCAG 2.5.3 asks for the visible text to be inside the name, and the superseded marker is where you find the breach.

What to check next

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.

intermediate10 minpublished updated Maks Verny