What 3.1.2 requires
The human language of each passage or phrase in the content can be programmatically determined except for proper names, technical terms, words of indeterminate language, and words or phrases that have become part of the vernacular of the immediately surrounding text.
A quote, a review or a name of a language in another language needs its own lang, so speech synthesizers pronounce it with the right rules and braille software translates it correctly. A single word can count when a change of language is intended.
The exceptions keep this practical: proper names, technical terms, words of indeterminate language, and words that became part of the surrounding language. Understanding 3.1.2 gives “rendezvous” in English and “podcast” in French as examples.
A wrong lang is a problem of its own: the screen reader switches to a voice that reads the passage with another language’s rules.
Common failures
Under each example: what axe-core and Rampa reported on the markup before the fix, in a run with Gemma 4 12B on 8 October 2026.
A passage marked with the wrong language
A review in German was marked as French. A screen reader switches to French and reads German words with French rules. The tag is valid, so the rules pass.
Before, fails 3.1.2
<blockquote lang="fr">
<p>Die Lieferung kam einen Tag früher
als angekündigt.</p>
</blockquote>After, passes
<blockquote lang="de">
…
</blockquote>- axe-core
Passes:
valid-langfinds a valid tag.- Rampa
✗ html > body > main > blockquote Marked as lang="fr", but the text is in German (de). Evidence: "Die Lieferung kam einen Tag früher als angekündigt." Patch: - <blockquote lang="fr"> + <blockquote lang="de"> confidence high · 1/1 runs · evidence verified · id d0eb6daacd91
A phrase in another language with no lang
The motto is Portuguese on an English page, with no lang on it, so it is read with English rules. Neither tool notices: axe-core has no tag to check, and Rampa judges only elements that declare a language.
Before, fails 3.1.2
<p>Our founder’s motto:
<q>Feito à mão, devagar e com cuidado.</q></p>After, passes
<p>Our founder’s motto:
<q lang="pt">Feito à mão, devagar e com cuidado.</q></p>- axe-core
Passes: there is no
langto check.- Rampa
Rampa reported nothing here. Finding passages that lack a
langis not built yet.
A tag with the wrong separator
Language tags use a hyphen: pt-BR, not pt_BR. axe-core fails the invalid tag, and Rampa reports that without asking a model.
Before, fails 3.1.2
<p lang="pt_BR">Entregamos em todo o Brasil.</p>After, passes
<p lang="pt-BR">Entregamos em todo o Brasil.</p>- axe-core
Fails:
valid-lang.- Rampa
✗ html > body > main > p:nth-of-type(2) lang attribute must have a valid value high · rule valid-lang
What axe-core checks
axe-core 4.14.0 maps one rule to 3.1.2: valid-lang, which fails a lang value that is not a valid language tag. Rampa reports its failures as they are, and an element it failed never goes to the model.
| Rule | What it checks | Mapped to | In a Rampa run |
|---|---|---|---|
valid-lang, rule page at Deque University | lang attribute must have a valid value | 3.1.2 | runs |
The rule does not read the text. A German review marked lang="fr" passes, and so does a passage with no lang at all.
Each rule links to its page at Deque University, which makes axe-core.
What Rampa judges, and how
What goes to the model
Every element other than the root that declares a valid lang, is not hidden, was not failed by valid-lang, and has at least four letters of text that inherit that language.
What the model sees
- The declared language, and the language inherited from around the element.
- The element’s markup, up to 800 characters.
- The text that inherits the declared language, in reading order, up to 1,200 characters, with the names of images from
altandaria-labelledby.
What it must answer
A verdict, the language of the text as a BCP 47 primary tag, a short excerpt copied as evidence, which 3.1.2 exception applies (a proper name, a technical term, indeterminate words, the surrounding vernacular, or none), and a confidence.
What drops a claim
- The excerpt is not in the element’s text, is shorter than two letters, or is longer than 400 characters.
- The detected language is not a valid BCP 47 tag.
- A fail detects the declared language, or claims that an exception applies.
- A pass detects another language without claiming an exception.
The patch
The element’s lang becomes the detected primary tag, such as de.
For every criterion
- Page content reaches the model marked as data, and the prompt tells it to ignore any instruction inside.
- Answers are cached by a hash of the prompt, the image, the model and its settings, so the same input never calls a model twice.
--runs 3asks three times and keeps the majority; when runs disagree, confidence drops. Findings below--min-confidence(medium by default) are hidden, and--verboselists them.- Claims dropped by verification are counted in every report, never shown as findings.
How Rampa was measured on it
| Set | Tests | Cases | axe-core, precision / recall | Rampa, precision / recall / F1 |
|---|---|---|---|---|
| 3.1.2, valid lang on parts (ACT de46e4) | syntax | 19 | 1.00 / 1.00 | 1.00 / 1.00 / 1.00 |
| 3.1.2, lang matches the text (ACT off6ek) | meaning | 13 | not defined, nothing flagged / 0.00 | 0.80 / 1.00 / 0.89 |
Corrupted pairs: Rampa told 4 of 5 apart (lang on parts), axe-core 0. A pair is a passing test page and a copy broken on purpose; a checker that gives both the same verdict is not judging.
Run on 9 October 2026: axe-core 4.14.0, Gemma 4 12B on a local GPU, reasoning off, one run, ACT test cases a9a1483e. Small samples and one run: read the numbers as a working pipeline, not as a result.
Read with these caveats
- The first run, on 2026-10-07, had recall 0.75 on off6ek. The misses never reached a model, because Rampa did not count image names from
aria-labelledbyas text; with that fixed, recall is 1.00. - The one pair Rampa did not tell apart is an intact page it also fails: “Paul put dire comment on tape”, a sentence that is English and French at once. On 1.1.1 and 3.1.2, the model errors of both Gemma and
openai:gpt-6-lunafell on that sentence. - On 1.1.1 and 3.1.2,
openai:gpt-6-lunagot the same numbers as Gemma in two runs with fresh calls, at about US$ 0.005 per run.
Limitations
- Only elements that declare a
langare judged. A passage in another language with nolangat all is not found, by Rampa or by axe-core. - Text shorter than four letters is skipped, and so are hidden elements.
- A sentence that mixes two languages is hard for a model, as the caveats above show.
- The patch proposes the primary tag only, such as
de. - Only web pages: snapshots from apps are not judged for 3.1.2.
Questions about 3.1.2
Do proper names and loanwords need lang?
No. 3.1.2 exempts proper names, technical terms, words of indeterminate language and words that became part of the surrounding language, such as “rendezvous” in English. Rampa asks the model which exception applies, and a pass that rests on an exception is allowed.
Does Rampa find foreign passages that have no lang?
Not yet. It judges only elements that declare a language, so a quote in another language without a lang attribute goes unnoticed, as it does in axe-core.
Does axe-core check language of parts?
It checks that each lang value is a valid tag (valid-lang). A valid tag on text in another language passes.
Check your pages
Rampa is not on npm yet. Clone it and run it from source with Node.js 22.12 or newer and Chrome or Edge. --criteria 3.1.2 judges only this criterion; leave it out to run the eight that run by default.
With Ollama running, Rampa picks a local model by itself; node dist/cli.mjs doctor says what is missing.
git clone https://github.com/guilhermebsantiago/rampa-cli.git
cd rampa-cli
pnpm install && pnpm build
node dist/cli.mjs check https://example.com --criteria 3.1.2Checked against rampa-cli (commit 0459809) and the W3C sources on 9 October 2026.