Page language must be declared and match the content
Screen readers use the declared language to choose the pronunciation. A German shop that declares English, which is common when every translation keeps the default language, is read out with the wrong accent, so product names, prices and instructions become hard to understand. Browser translation and spell-checking depend on it too.
- Rule id
- vs-lang
- Source
- Validiscan check
- Impact
- Moderate
- Fix tier
- AUTO
- on Shopify, Shopware, WooCommerce and Magento
- Status
- runs
What the check does
Flags a missing or invalid lang attribute on the <html> element and a mismatch between the declared language and the language detected in the page content (3.1.1). Supersedes the axe rules html-has-lang and html-lang-valid.
The check reads only the lang attribute of the <html> element. A value is valid when it is a well-formed BCP 47 tag whose primary subtag is a known two-letter ISO 639-1 code or has three letters. Private-use tags such as x-shop pass; en_US, english and xx do not.
For the comparison, the check takes the text of main or role="main" when it holds at least 300 characters, otherwise the whole body, and counts common function words for English, German, Danish, French, Spanish, Dutch, Italian, Swedish, Norwegian, Polish and Portuguese.
A mismatch is a finding only when the text has at least 300 characters and the leading language has at least 10 matches, at least 8% of all words, twice the declared language’s matches and at least 8 more. Weaker evidence does not produce a finding, and neither does a mismatch on a page declared in a language outside these eleven. lang on other elements is not checked.
How to fix it
Output the lang attribute on <html> from the current storefront locale, for example <html lang="de">, so every language version declares its own valid code (de, da, fr-BE and so on) instead of a hardcoded default.
WCAG success criteria
- 3.1.1 Language of Page, level A
EN 301 549 clauses
| WCAG criterion | Version 3.2.1 | Version 4.1.1 |
|---|---|---|
| 3.1.1 | 9.3.1.1 | 9.3.1.1 |
Where a recipe fixes it
Shopify: recipe html-lang
Show the before and after (1)
layout/checkout.liquid Changes to layout/checkout.liquid: 1 added, 1 removed
| Line before | Line after | Change | Code |
|---|---|---|---|
| 3 | 3 | Checkout layout (the Shopify Plus `checkout.liquid` era). Only the first step | |
| 4 | 4 | (contact, delivery, billing) is modelled; nothing is ever submitted. | |
| 5 | 5 | {% endcomment %} | |
| 6 | Removed | <html class="no-js"> | |
| 6 | Added | <html class="no-js" lang="{{ request.locale.iso_code }}"> | |
| 7 | 7 | <head> | |
| 8 | 8 | <meta charset="utf-8"> | |
| 9 | 9 | <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"> |
layout/theme.liquid Changes to layout/theme.liquid: 3 added, 0 removed
| Line before | Line after | Change | Code |
|---|---|---|---|
| 6 | 6 | {% endcomment %} | |
| 7 | 7 | {%- liquid | |
| 8 | 8 | assign page_lang = request.locale.iso_code | |
| 9 | Added | if page.metafields.custom.content_language != blank | |
| 10 | Added | assign page_lang = page.metafields.custom.content_language | |
| 11 | Added | endif | |
| 9 | 12 | -%} | |
| 10 | 13 | <html class="no-js" lang="{{ page_lang }}"> | |
| 11 | 14 | <head> |
Shopware: recipe html-lang
Show the before and after (1)
Resources/views/storefront/base.html.twig Changes to Resources/views/storefront/base.html.twig: 1 added, 1 removed
| Line before | Line after | Change | Code |
|---|---|---|---|
| 9 | 9 | {% block base_doctype %}<!DOCTYPE html>{% endblock %} | |
| 10 | 10 | ||
| 11 | 11 | {% block base_html %} | |
| 12 | Removed | <html lang="{{ page.lang }}" itemscope="itemscope" itemtype="https://schema.org/WebPage"> | |
| 12 | Added | <html lang="{{ page.lang|replace({'_': '-'}) }}" itemscope="itemscope" itemtype="https://schema.org/WebPage"> | |
| 13 | 13 | {% endblock %} | |
| 14 | 14 | ||
| 15 | 15 | {% block base_head %} |
WooCommerce: recipe html-lang
Show the before and after (1)
header-checkout.php Changes to header-checkout.php: 1 added, 1 removed
| Line before | Line after | Change | Code |
|---|---|---|---|
| 8 | 8 | ||
| 9 | 9 | ?><!doctype html> | |
| 10 | 10 | <?php // VALIDISCAN-SEED vs-lang: copied from a landing-page plugin, language_attributes() is missing, so <html> has no lang. ?> | |
| 11 | Removed | <html> | |
| 11 | Added | <html <?php language_attributes(); ?>> | |
| 12 | 12 | <head> | |
| 13 | 13 | <meta charset="<?php bloginfo( 'charset' ); ?>"> | |
| 14 | 14 | <?php // VALIDISCAN-SEED meta-viewport: zooming is disabled. ?> |
Magento: recipe html-lang
Show the before and after (1)
app/design/frontend/Validiscan/fixture/Magento_Theme/templates/root.phtml Changes to app/design/frontend/Validiscan/fixture/Magento_Theme/templates/root.phtml: 1 added, 1 removed
| Line before | Line after | Change | Code |
|---|---|---|---|
| 15 | 15 | ?> | |
| 16 | 16 | <!doctype html> | |
| 17 | 17 | <!-- VALIDISCAN-SEEDED vs-lang: root.phtml prints the Magento locale code (en_US) instead of a BCP 47 language tag --> | |
| 18 | Removed | <html lang="<?= $block->escapeHtmlAttr($htmlLocale) ?>"> | |
| 18 | Added | <html <?= /* @noEscape */ $htmlAttributes ?>> | |
| 19 | 19 | <head <?= /* @noEscape */ $headAttributes ?>> | |
| 20 | 20 | <?= /* @noEscape */ $requireJs ?> | |
| 21 | 21 | <?= /* @noEscape */ $headContent ?> |
Start with a free check of your shop.
Send your shop’s address. Validiscan scans it and replies with every failure the scan can prove, with the page and the code.
Request a free checkOr write to info@validiscan.com.
What the free check covers, and what it leaves out- You send
- your shop’s address
- Pages scanned
- up to 15
- You get
- every failure the scan can prove, with the page and the code
- On your shop
- public pages only; no login, nothing bought, no forms sent
- What it is not
- a full audit