How to check alt text on images

Read every image through the accessibility tree, not through the markup. An image with no alt attribute reaches the tree as role=image name="". An image with alt="" is removed from the tree entirely. The first is a defect, the second is a correct decorative marking, and a grep cannot tell them apart.

Why check this

Run this on every page that ships new images, and on any template change touching an image component, before staging sign-off. Image alternatives break silently: nothing renders differently, no console message appears, the build passes.

The defect it catches is the one a text search misses. grep -c 'alt=' counts attributes and answers a question nobody asked. It cannot see that one image has no attribute at all, that another was given its file name as text, or that a third sits alone inside a link and leaves that link with no name. The tree separates all three in one pass.

Prerequisites

// server.mjs
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
const port = Number(process.argv[2] || 8742);
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="80" height="60">' +
            '<rect width="80" height="60" fill="#999"/></svg>';
createServer(async (req, res) => {
  const path = req.url.split('?')[0];
  if (path.endsWith('.svg')) {
    res.writeHead(200, { 'content-type': 'image/svg+xml' });
    return res.end(svg);
  }
  try {
    const body = await readFile(new URL('.' + path, import.meta.url));
    res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
    res.end(body);
  } catch {
    res.writeHead(404, { 'content-type': 'text/plain' });
    res.end('not found');
  }
}).listen(port, () => console.log('serving on http://127.0.0.1:' + port + '/'));
<!-- alt.html -->
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Quarterly report</title></head>
<body>
  <h1>Quarterly report</h1>
  <img src="/revenue.svg">
  <img src="/divider.svg" alt="">
  <img src="/logo.svg" alt="Acme logo">
  <img src="/chart-2024-q4.svg" alt="chart-2024-q4.svg">
  <a href="/"><img src="/logo.svg" alt=""></a>
  <img src="/seats.svg" alt="Seats sold, 1420" role="presentation">
  <input type="image" src="/send.svg">
</body>
</html>
// alt-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 { nodes } = await cdp.send('Accessibility.getFullAXTree');
const byNode = new Map(nodes.map((n) => [n.backendDOMNodeId, n]));
const { root } = await cdp.send('DOM.getDocument', { depth: -1 });
const { nodeIds } = await cdp.send('DOM.querySelectorAll', {
  nodeId: root.nodeId, selector: 'img, input[type=image]',
});
for (const nodeId of nodeIds) {
  const { node } = await cdp.send('DOM.describeNode', { nodeId });
  const attrs = {};
  for (let i = 0; i < node.attributes.length; i += 2) attrs[node.attributes[i]] = node.attributes[i + 1];
  const n = byNode.get(node.backendNodeId);
  const alt = 'alt' in attrs ? JSON.stringify(attrs.alt) : '(attribute absent)';
  const tree = n ? `role=${n.role.value} name=${JSON.stringify(n.name?.value ?? '')}` : 'not in the tree';
  console.log((attrs.src ?? '').padEnd(22), alt.padEnd(22), tree);
}
await browser.close();
// locale.mjs
import { launch } from 'puppeteer-core';
const CHROME = process.env.CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe';
for (const lang of [null, 'en-US']) {
  const browser = await launch({ executablePath: CHROME, headless: true, args: lang ? ['--lang=' + lang] : [] });
  const page = await browser.newPage();
  const cdp = await page.createCDPSession();
  await page.goto('http://127.0.0.1:8742/alt.html', { waitUntil: 'networkidle2' });
  const { root } = await cdp.send('DOM.getDocument');
  const { nodeId } = await cdp.send('DOM.querySelector', { nodeId: root.nodeId, selector: 'input[type=image]' });
  const [n] = (await cdp.send('Accessibility.getPartialAXTree', { nodeId, fetchRelatives: false })).nodes;
  console.log(`--lang=${lang ?? '(default)'}  navigator.language=${await page.evaluate(() => navigator.language)}  name=${JSON.stringify(n.name.value)}`);
  await browser.close();
}

Steps

  1. Step 1.

    Put the attribute and the tree node side by side for every image.

    node alt-audit.mjs http://127.0.0.1:8742/alt.html
    
    /revenue.svg           (attribute absent)     role=image name=""
    /divider.svg           ""                     not in the tree
    /logo.svg              "Acme logo"            role=image name="Acme logo"
    /chart-2024-q4.svg     "chart-2024-q4.svg"    role=image name="chart-2024-q4.svg"
    /logo.svg              ""                     not in the tree
    /seats.svg             "Seats sold, 1420"     not in the tree
    /send.svg              (attribute absent)     role=button name="Надіслати"

    Read the middle and right columns together. Line 1: no attribute, so the image stays in the tree with an empty name. Line 2: an empty alt, so the image left the tree, the correct marking for a divider. Line 3 passes. Line 4 names the image after its file. Line 6 has real alternative text and role="presentation" throws it away.

  2. Step 2.

    Follow the fifth line. That image is decorative inside a link, so the link has nothing left to name it.

    node ax-name.mjs http://127.0.0.1:8742/alt.html "a"
    
    a  role=link  name=""
    aria-labelledby  (absent)
    aria-label       (absent)
    contents         (absent)
    title            (absent)

    contents is (absent), not empty: the decorative image is no longer in the tree, so it contributed nothing. A logo linking home needs alt="Acme home", or a name on the link. Marking it decorative removes the link's only text.

  3. Step 3.

    Follow the last line. An input type="image" with no alt has no author name, so Chrome invents one. Launch twice, once with the machine default and once with an explicit interface language.

    node locale.mjs
    
    --lang=(default)  navigator.language=uk-UA  name="Надіслати"
    --lang=en-US  navigator.language=en-US  name="Submit"

    The control is named with the word Submit in whatever language the browser interface uses, so the name follows the browser, not the page. Chrome runs in Ukrainian on the capture machine, so an English page exposed a Ukrainian control name. A check asserting name === 'Submit' passes on the build agent and fails on a tester's laptop.

  4. Step 4.

    Count images on a page that was not written for this exercise.

    node ax-count.mjs https://developer.mozilla.org/en-US/docs/Web/HTTP
    
    dom elements 2000   ax nodes 1067
    img 0  svg 4  ->  ax image nodes 10
    …
    image  "Auth0"
    image  "MDN"
    image  "Mozilla"
    image  "MDN"
    image  ""
    image  ""
    image  ""
    image  "Experimental"
    image  ""
    image  ""

    Zero img elements in the markup, ten image nodes in the tree, six of them with an empty name. A check built on <img finds nothing here. A check built on the tree finds ten things to look at.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | (attribute absent) with role=image name="" | The alt attribute is missing | Add one. Write the text if the image carries meaning, alt="" if it does not. | | "" with not in the tree | Correctly marked decorative | Confirm the image really carries no information. Nothing else to do. | | The file name as the name | A generator filled the attribute | Replace it. A file name is noise in the position where the meaning belongs. | | Alternative text with not in the tree | role="presentation" or aria-hidden overrode it | Remove the role, or delete the text so the intent is not ambiguous. | | role=button on an image input | The image is a control, not an illustration | Its alt is the button label. Missing means the browser names it, in its own language. |

Common mistakes

Sign: A missing alt attribute and an empty one are reported as the same finding.Cause: They are opposite instructions. In the capture above, the image with no attribute stayed in the tree with an empty name, and the image with an empty alt was removed from the tree. One is an unnamed image, the other is a decoration correctly kept out of the way. An audit that lists them together produces a report nobody can act on.
Sign: A decorative image inside a link is passed because an empty alt is the correct marking.Cause: The rule is about the image and the failure is on the link. Step 2 shows contents as absent and the link with no name. The same attribute is right on a divider and wrong on a logo that links to the home page.
Sign: An automated name assertion on an image button passes locally and fails on another machine.Cause: An input of type image with no alt is named from Chrome's own interface language. The same page returned Надіслати from one launch and Submit from the next, on one machine, with only the --lang flag changed. The assertion was testing the build agent's locale.
Sign: Image coverage is measured by counting alt attributes in the HTML.Cause: The MDN page in step 4 has zero img elements and ten image nodes in the tree. SVG, CSS content and role=img on other elements all produce image nodes. The markup count is not a floor and not a ceiling.

What to check next

FAQ

How do I check if an image has alt text?

Run step 1 and read the middle column. (attribute absent) means no attribute at all. An empty value means the author marked the image decorative. Anything else is the text. The right column says what the browser did with it.

Is alt="" ever correct?

Yes, for an image that carries no information: a divider, a spacer, a decorative background. The browser then removes the image from the tree entirely, which is what you want. It is wrong when the image is the only content of a link or a button.

How do I check alt text of a single image?

Select it in DevTools, Elements panel, then read the Accessibility pane. The computed name is the alt text once the browser has resolved it. For a scripted answer, run ax-name.mjs with a selector for that image.

How do I test alt text on a whole website?

Run step 1 once per URL from the sitemap and diff the report between builds. The script takes the URL as its argument, so a loop over a list is the whole job.

Do SVG and CSS images need alternatives?

Inline svg needs role="img" and a name, or aria-hidden="true" if decorative. A CSS background image cannot carry one, so what it communicates has to exist in text too.

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.

basic7 minpublished updated Maks Verny