How to check the html lang attribute
Read document.documentElement.lang, then test the tag with Intl.getCanonicalLocales. A missing attribute and an empty one both report an empty string, so read hasAttribute('lang') as well. In the capture below lang="EN_US" throws a RangeError and matches no :lang(en) rule, while lang="en-US" matches seven elements.
Why check this
Run this on every template, before release, and again after a localisation build starts writing the tag from a config value. It takes under a minute and it is the check most often skipped, because the attribute lives in a layout file that nobody opens.
The failure is a page read out in the wrong voice. A speech synthesiser picks its pronunciation rules from the document language. With no language it falls back to the user's own, so English product text is read with German phonetics and stops being words. Hyphenation, quotation marks and the spell checker take the same value, so the defect reaches sighted users too.
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. All three import it. - A local page with one defect per route. Save it as
lang-demo.mjs, on a port nothing else is using, and stop it afterwards by its PID.
// lang-demo.mjs - six pages, one lang defect each. node lang-demo.mjs
import { createServer } from 'node:http';
const html = {
'/missing': '<html>',
'/empty': '<html lang="">',
'/underscore': '<html lang="EN_US">',
'/valid': '<html lang="en-US">',
'/inline': '<html lang="en-US">',
'/wrong': '<html lang="en">',
};
const body = {
'/inline': '<p>The German term is <span lang="de">Dateiendung</span>, and the French one is <span lang="fr-CA">extension</span>.</p>',
'/wrong': '<p>Die Datei wird auf dem Server gespeichert und kann von dem Nutzer mit einem Klick wieder geladen werden.</p>',
};
createServer((req, res) => {
const url = req.url.split('?')[0];
res.setHeader('content-type', 'text/html; charset=utf-8');
res.end(`<!doctype html>${html[url] ?? '<html lang="en-US">'}<head><meta charset="utf-8"><title>Lang demo</title></head>
<body><h1>Glossary</h1>${body[url] ?? '<p>File extension.</p>'}</body></html>`);
}).listen(9153, () => console.log('http://localhost:9153/missing /empty /underscore /valid /inline /wrong'));
- The figures come from one capture, on one machine, with Chrome 152.0.7977.76.
- WCAG 3.1.1 Language of Page is Level A and WCAG 3.1.2 Language of Parts is Level AA. The tag itself is defined by BCP 47.
Steps
- Step 1.
Start the demo server and leave it running in its own terminal.
node lang-demo.mjshttp://localhost:9153/missing /empty /underscore /valid /inline /wrong - Step 2.
Read the attribute four ways on each page: the raw attribute, the DOM property, the canonical form of the tag, and what the tag matches in CSS.
// lang.mjs - node lang.mjs <url> [<url> ...] import { open } from './ax.mjs'; const s = await open(); try { for (const url of process.argv.slice(2)) { await s.goto(url); console.log(await s.page.evaluate((u) => { const html = document.documentElement; const attr = html.getAttribute('lang'); let canonical; try { canonical = Intl.getCanonicalLocales(attr || 'und').join(','); } catch (e) { canonical = e.name + ': ' + e.message; } return [ `${u}`, ` hasAttribute('lang') = ${html.hasAttribute('lang')}`, ` getAttribute('lang') = ${JSON.stringify(attr)}`, ` documentElement.lang = ${JSON.stringify(html.lang)}`, ` Intl.getCanonicalLocales -> ${canonical}`, ` html.matches(':lang(en)') = ${html.matches(':lang(en)')}`, ` :lang(en) elements = ${document.querySelectorAll(':lang(en)').length}`, ].join('\n'); }, url)); } } finally { await s.close(); }node lang.mjs http://localhost:9153/missing http://localhost:9153/empty http://localhost:9153/underscore http://localhost:9153/validhttp://localhost:9153/missing hasAttribute('lang') = false getAttribute('lang') = null documentElement.lang = "" Intl.getCanonicalLocales -> und html.matches(':lang(en)') = false :lang(en) elements = 0 http://localhost:9153/empty hasAttribute('lang') = true getAttribute('lang') = "" documentElement.lang = "" Intl.getCanonicalLocales -> und html.matches(':lang(en)') = false :lang(en) elements = 0 http://localhost:9153/underscore hasAttribute('lang') = true getAttribute('lang') = "EN_US" documentElement.lang = "EN_US" Intl.getCanonicalLocales -> RangeError: Invalid language tag: EN_US html.matches(':lang(en)') = false :lang(en) elements = 0 http://localhost:9153/valid hasAttribute('lang') = true getAttribute('lang') = "en-US" documentElement.lang = "en-US" Intl.getCanonicalLocales -> en-US html.matches(':lang(en)') = true :lang(en) elements = 7The first two pages differ in the markup and agree in every reading except
hasAttribute. The third carries a tag that no parser accepts, and the page renders without a warning of any kind. - Step 3.
List the passages that declare their own language, and count the ones that inherit the document language.
// lang-inline.mjs - node lang-inline.mjs <url> import { open } from './ax.mjs'; const s = await open(process.argv[2]); try { console.log(await s.page.evaluate(() => { const out = []; for (const e of document.querySelectorAll('[lang]')) { const tag = e.getAttribute('lang'); let valid = true; try { Intl.getCanonicalLocales(tag); } catch { valid = false; } const inherited = e.parentElement?.closest('[lang]')?.getAttribute('lang') ?? '(none)'; out.push(` <${e.tagName.toLowerCase()} lang="${tag}"> valid=${valid} inherits-from="${inherited}" text="${e.textContent.trim().slice(0, 24)}"`); } const noLang = [...document.querySelectorAll('body *')].filter( (e) => !e.children.length && e.textContent.trim() && !e.closest('[lang]:not(html)') ).length; out.push(` ${out.length} element(s) carry lang, ${noLang} leaf element(s) fall back to the document language`); return out.join('\n'); })); } finally { await s.close(); }node lang-inline.mjs http://localhost:9153/inline<html lang="en-US"> valid=true inherits-from="(none)" text="Lang demo GlossaryThe Ge" <span lang="de"> valid=true inherits-from="en-US" text="Dateiendung" <span lang="fr-CA"> valid=true inherits-from="en-US" text="extension" 3 element(s) carry lang, 1 leaf element(s) fall back to the document languageTwo foreign words, each wrapped in its own tag, each inheriting from
en-US. That is what 3.1.2 asks for, and it is the part a page-level check never reaches. - Step 4.
Compare the declared language with the language the body text is written in. The scan counts function words, which is enough to catch a template that ships one tag for every locale.
// lang-content.mjs - node lang-content.mjs <url> import { open } from './ax.mjs'; const s = await open(process.argv[2]); try { console.log(await s.page.evaluate(() => { const STOP = { en: ['the', 'and', 'of', 'to', 'is', 'in', 'a', 'with', 'from', 'on'], de: ['der', 'die', 'das', 'und', 'von', 'mit', 'auf', 'dem', 'ein', 'wird'], fr: ['le', 'la', 'les', 'et', 'de', 'du', 'des', 'un', 'une', 'est'], es: ['el', 'la', 'los', 'de', 'y', 'que', 'en', 'un', 'una', 'con'], }; const words = document.body.innerText.toLowerCase().match(/[\p{L}']+/gu) ?? []; const score = Object.fromEntries( Object.entries(STOP).map(([l, list]) => [l, words.filter((w) => list.includes(w)).length]) ); const best = Object.entries(score).sort((a, b) => b[1] - a[1])[0]; const declared = document.documentElement.lang; return [ ` declared: ${declared || '(none)'}`, ` stopword hits: ${JSON.stringify(score)}`, ` content looks like: ${best[1] ? best[0] : 'undecided'} (${best[1]} of ${words.length} words)`, ` verdict: ${best[1] && !declared.toLowerCase().startsWith(best[0]) ? 'MISMATCH' : 'consistent'}`, ].join('\n'); })); } finally { await s.close(); }node lang-content.mjs http://localhost:9153/wrongdeclared: en stopword hits: {"en":0,"de":8,"fr":0,"es":0} content looks like: de (8 of 19 words) verdict: MISMATCHEight German function words in nineteen, and a tag that says English. Every reading in step 2 passes on this page.
- Step 5.
Run the same reading against a public page, so you know the script agrees with a page that is correct.
node lang.mjs https://example.com/https://example.com/ hasAttribute('lang') = true getAttribute('lang') = "en" documentElement.lang = "en" Intl.getCanonicalLocales -> en html.matches(':lang(en)') = true :lang(en) elements = 12A two-letter tag, canonical, and every element in the document inherits it. Captured on 2026-09-11.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| hasAttribute = false | The attribute is absent | Add lang to the html element in the layout |
| hasAttribute = true with an empty value | A template wrote an empty variable | Fix the value, and fail the build when it is empty |
| RangeError: Invalid language tag | The tag is not BCP 47 | Replace the underscore with a hyphen: en-US, not EN_US |
| :lang(en) elements = 0 on an English page | No rule keyed to the language applies any more | Fix the tag, then re-run and count again |
| verdict: MISMATCH | The tag disagrees with the text | Set the tag per locale, not once for the whole build |
| A passage with no lang of its own | It inherits the document language | Wrap foreign passages in a span with their own tag |
Common mistakes
What to check next
- How to check accessibility tree: where the document language ends up once the browser has computed it.
- How to check heading structure of a page: the next structural check on the same template.
- How to check landmark regions of a page: landmark names are read in the document language, so the two defects compound.
- How to check link text for accessibility: link names are spoken with the pronunciation rules this attribute selects.
FAQ
What does missing required lang attribute mean?
An audit tool found no lang on the html element. The page renders, and the speech synthesiser falls back to the user's own language for pronunciation. It is a WCAG 3.1.1 failure at Level A, and the fix is one attribute in the layout file.
Is lang="en" enough, or do I need lang="en-US"?
en is enough for 3.1.1. Add the region when something depends on it, such as a date format or a spelling dictionary. Both are valid BCP 47 tags, and step 2 shows both passing Intl.getCanonicalLocales.
How do I check the lang attribute of a page quickly?
Open DevTools, Elements panel, and read the opening html tag. That catches missing and empty. It will not tell you that EN_US is invalid, which is why step 2 runs the tag through Intl.getCanonicalLocales.
Does the lang attribute affect anything besides screen readers?
Yes. It selects hyphenation, the quotation marks CSS inserts, the spell checker dictionary, and font selection for scripts with regional shapes. On the demo page the :lang(en) count went from seven to zero when the tag became invalid, and every rule keyed to language stopped applying.
Do I need lang on every foreign word?
On passages, not on proper nouns or on words that have entered the document language. Step 3 marks two terms, each with its own tag. WCAG 3.1.2 is Level AA and lists names and technical terms as exceptions.
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