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
- Chrome 120 or later and Node 22.
npm i puppeteer-coreinstalls the driver only: it ships no browser and drives the Chrome already installed. SetCHROMEif yours is elsewhere. - HTML Accessibility API Mappings decides this: an
imgwith an emptyaltmaps to rolenoneorpresentation, a plainimgto roleimage. - A local page carrying the cases, and a server that answers both the HTML and the image requests. Save both in one directory, run
node server.mjs 8742, and stop it by PID afterwards, never by image name.
// 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>
- Four scripts in the same directory.
ax-name.mjsandax-count.mjscome from How to check accessibility tree. These two are new.
// 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();
}
- Every figure below is one capture on one machine, Chrome 152.0.7977.76, 2026-09-11.
Steps
- 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 androle="presentation"throws it away. - 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)contentsis(absent), not empty: the decorative image is no longer in the tree, so it contributed nothing. A logo linking home needsalt="Acme home", or a name on the link. Marking it decorative removes the link's only text. - Step 3.
Follow the last line. An
input type="image"with noalthas 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. - 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/HTTPdom 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
imgelements in the markup, ten image nodes in the tree, six of them with an empty name. A check built on<imgfinds 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
What to check next
- How to check accessibility tree: the tree reading these steps depend on.
- How to check aria labels: what happens when
aria-labelis used instead ofalt. - How to check link text for accessibility: the other half of the image-inside-a-link failure in step 2.
- How to check if images are lazy loaded: images below the fold still need alternatives.
- How to run an axe accessibility test: rule
image-altcovers the missing attribute across a whole site.
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.
Related on this site
basic7 minpublished updated Maks Verny