View contents
Introduction
The HTML lang attribute tells browsers and assistive technologies what human language your content is in. WCAG 3.1.1 (Language of Page) requires every web page to have its primary language declared. This seemingly simple attribute has profound impacts on accessibility, pronunciation, and user experience.
Screen readers use the language attribute to select the correct pronunciation rules and voice. Without proper language identification, a screen reader might read French text with English pronunciation, making the content incomprehensible to users.
What WCAG Requires
3.1.1 Language of Page (Level A)
The default human language of each Web page can be programmatically determined.
This means:
- Every HTML page must have a
langattribute on the<html>element - The value must be a valid BCP 47 language tag (e.g., “en”, “es”, “fr”)
- This allows assistive technologies to load appropriate pronunciation rules
3.1.2 Language of Parts (Level AA)
The human language of each passage or phrase in the content can be programmatically determined.
This means:
- Content in a different language than the page default should have its own
langattribute - This applies to quotes, phrases, or sections in foreign languages
- Exceptions: proper names, technical terms, words that have become part of the surrounding language
Who Benefits
| User Type | Benefit |
|---|---|
| Screen reader users | Correct pronunciation of content |
| Users of translation tools | Accurate translation targeting |
| Users with reading disabilities | Proper hyphenation and text processing |
| Search engines | Better understanding of content language |
| All users | Correct spell-checking and auto-correction |
Basic Implementation
Page Language
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Page Title</title>
</head>
<body>
<!-- Page content in English -->
</body>
</html>
Language of Parts
<p>
The French phrase <span lang="fr">c'est la vie</span> means "that's life".
</p>
<blockquote lang="es">
<p>La vida es sueño.</p>
<cite>— Calderón de la Barca</cite>
</blockquote>
Common Language Codes
| Language | Code | Extended Variants |
|---|---|---|
| English | en |
en-US, en-GB, en-AU |
| Spanish | es |
es-ES, es-MX, es-AR |
| French | fr |
fr-FR, fr-CA |
| German | de |
de-DE, de-AT, de-CH |
| Portuguese | pt |
pt-BR, pt-PT |
| Chinese | zh |
zh-CN, zh-TW, zh-Hans, zh-Hant |
| Japanese | ja |
ja-JP |
| Korean | ko |
ko-KR |
| Arabic | ar |
ar-SA, ar-EG |
| Russian | ru |
ru-RU |
Common Problems
1. Missing lang Attribute
<!-- BAD: No language specified -->
<!DOCTYPE html>
<html>
<head>...</head>
<body>...</body>
</html>
<!-- GOOD: Language declared -->
<!DOCTYPE html>
<html lang="en">
<head>...</head>
<body>...</body>
</html>
2. Wrong Language Code
<!-- BAD: Invalid language codes -->
<html lang="english">
<html lang="eng">
<html lang="en_US">
<!-- GOOD: Valid BCP 47 codes -->
<html lang="en">
<html lang="en-US">
3. Foreign Phrases Without Language Tags
<!-- BAD: French pronounced with English rules -->
<p>The phrase croissant originated in Austria.</p>
<!-- GOOD: French word marked for correct pronunciation -->
<p>The phrase <span lang="fr">croissant</span> originated in Austria.</p>
4. Language Mismatch
<!-- BAD: Page in Spanish but marked as English -->
<html lang="en">
<body>
<h1>Bienvenido a nuestro sitio</h1>
<!-- Spanish content... -->
</body>
</html>
<!-- GOOD: Correct language declaration -->
<html lang="es">
<body>
<h1>Bienvenido a nuestro sitio</h1>
</body>
</html>
When to Mark Language of Parts
Do Mark
- Foreign words that might be mispronounced
- Quotations in other languages
- Passages or sections in different languages
- Technical terms from other languages (when pronunciation matters)
<!-- Foreign quote -->
<blockquote lang="de">
<p>Ich bin ein Berliner.</p>
</blockquote>
<!-- Foreign phrase -->
<p>The <span lang="la">status quo</span> must change.</p>
Don’t Need to Mark
- Proper names (like “François” or “München”)
- Technical terms widely used (like “pizza” or “entrepreneur”)
- Words adopted into English (like “café” or “fiancé”)
- Brand names
<!-- These don't need lang attributes -->
<p>Meet our CEO, François Martin.</p>
<p>We ordered pizza for the team.</p>
<p>She is his fiancée.</p>
Impact on Screen Readers
When a screen reader encounters text, it checks the lang attribute to select pronunciation rules:
| Without lang | With lang |
|---|---|
| “Bohn-joor” (English pronunciation) | “Bohn-zhoor” (French pronunciation) |
| Screen reader guesses language | Screen reader knows exact language |
| Potentially incomprehensible | Clear, correct pronunciation |
Testing Language Attributes
Quick Manual Test
- Check that
<html>has alangattribute - Verify the language code matches the page content
- Look for foreign phrases and check for
langattributes - Use a screen reader to verify pronunciation
What to Verify
- [ ] Page has
langattribute on<html>element - [ ] Language code is valid BCP 47 format
- [ ] Language matches actual page content
- [ ] Foreign phrases have appropriate
langattributes - [ ] Screen reader pronounces content correctly
Tools for Testing
- axe DevTools - Flags missing page language
- WAVE - Shows language attributes
- Browser DevTools - Inspect
<html>element - W3C Validator - Validates language codes
Best Practices Summary
| Do | Don’t |
|---|---|
Always include lang on <html> |
Leave lang attribute missing |
| Use valid BCP 47 codes | Use full language names or invalid codes |
| Mark foreign phrases | Leave foreign content unmarked |
| Match lang to actual content | Use wrong language code |
| Test with screen readers | Assume pronunciation is correct |
Implementation
Complete HTML Implementation
Page Language Template
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Language declaration in meta (supplementary) -->
<meta http-equiv="content-language" content="en">
<!-- Alternate language versions (SEO and accessibility) -->
<link rel="alternate" hreflang="es" href="https://example.com/es/page">
<link rel="alternate" hreflang="en" href="https://example.com/en/page">
<link rel="alternate" hreflang="x-default" href="https://example.com/en/page">
<title>Page Title</title>
</head>
<body>
<!-- Content in primary language (English) -->
<!-- Section in different language -->
<section lang="es">
<h2>Sección en Español</h2>
<p>Este contenido está en español.</p>
</section>
<!-- Inline language changes -->
<p>
The concept of <span lang="de">Zeitgeist</span> refers to the
spirit of the time.
</p>
</body>
</html>
BCP 47 Language Tag Structure
language-extlang-script-region-variant-extension-privateuse
Examples:
en → English (general)
en-US → English (United States)
en-GB → English (United Kingdom)
zh-Hans → Chinese (Simplified script)
zh-Hant → Chinese (Traditional script)
zh-CN → Chinese (China region, implies Simplified)
zh-TW → Chinese (Taiwan region, implies Traditional)
pt-BR → Portuguese (Brazil)
sr-Latn → Serbian (Latin script)
sr-Cyrl → Serbian (Cyrillic script)
Complete Language Tag Reference
const LANGUAGE_TAGS = {
// Major Languages
'en': 'English',
'en-US': 'English (United States)',
'en-GB': 'English (United Kingdom)',
'en-AU': 'English (Australia)',
'en-CA': 'English (Canada)',
'es': 'Spanish',
'es-ES': 'Spanish (Spain)',
'es-MX': 'Spanish (Mexico)',
'es-AR': 'Spanish (Argentina)',
'es-CO': 'Spanish (Colombia)',
'es-CL': 'Spanish (Chile)',
'fr': 'French',
'fr-FR': 'French (France)',
'fr-CA': 'French (Canada)',
'fr-BE': 'French (Belgium)',
'de': 'German',
'de-DE': 'German (Germany)',
'de-AT': 'German (Austria)',
'de-CH': 'German (Switzerland)',
'pt': 'Portuguese',
'pt-BR': 'Portuguese (Brazil)',
'pt-PT': 'Portuguese (Portugal)',
// Asian Languages
'zh': 'Chinese',
'zh-Hans': 'Chinese (Simplified)',
'zh-Hant': 'Chinese (Traditional)',
'zh-CN': 'Chinese (China)',
'zh-TW': 'Chinese (Taiwan)',
'zh-HK': 'Chinese (Hong Kong)',
'ja': 'Japanese',
'ja-JP': 'Japanese (Japan)',
'ko': 'Korean',
'ko-KR': 'Korean (South Korea)',
// RTL Languages
'ar': 'Arabic',
'ar-SA': 'Arabic (Saudi Arabia)',
'ar-EG': 'Arabic (Egypt)',
'he': 'Hebrew',
'fa': 'Persian/Farsi',
// Other European
'it': 'Italian',
'nl': 'Dutch',
'ru': 'Russian',
'pl': 'Polish',
'sv': 'Swedish',
'da': 'Danish',
'no': 'Norwegian',
'fi': 'Finnish',
'el': 'Greek',
'tr': 'Turkish',
// Classical/Historical
'la': 'Latin',
'grc': 'Ancient Greek',
};
Framework Implementations
React with i18n
import React, { createContext, useContext, useEffect } from 'react';
// Language Context
interface LanguageContextType {
language: string;
setLanguage: (lang: string) => void;
dir: 'ltr' | 'rtl';
}
const LanguageContext = createContext<LanguageContextType | null>(null);
// RTL Languages
const RTL_LANGUAGES = ['ar', 'he', 'fa', 'ur'];
export function LanguageProvider({
children,
defaultLanguage = 'en'
}: {
children: React.ReactNode;
defaultLanguage?: string;
}) {
const [language, setLanguageState] = React.useState(defaultLanguage);
const dir = RTL_LANGUAGES.some(l => language.startsWith(l)) ? 'rtl' : 'ltr';
const setLanguage = (lang: string) => {
setLanguageState(lang);
// Update document attributes
document.documentElement.lang = lang;
document.documentElement.dir = RTL_LANGUAGES.some(l => lang.startsWith(l))
? 'rtl'
: 'ltr';
};
// Set initial document language
useEffect(() => {
document.documentElement.lang = language;
document.documentElement.dir = dir;
}, []);
return (
<LanguageContext.Provider value={{ language, setLanguage, dir }}>
{children}
</LanguageContext.Provider>
);
}
export function useLanguage() {
const context = useContext(LanguageContext);
if (!context) {
throw new Error('useLanguage must be used within LanguageProvider');
}
return context;
}
// Component for inline language changes
interface LangSpanProps {
lang: string;
children: React.ReactNode;
className?: string;
}
export function LangSpan({ lang, children, className }: LangSpanProps) {
const isRtl = RTL_LANGUAGES.some(l => lang.startsWith(l));
return (
<span
lang={lang}
dir={isRtl ? 'rtl' : undefined}
className={className}
>
{children}
</span>
);
}
// Component for block-level language changes
interface LangBlockProps {
lang: string;
children: React.ReactNode;
as?: 'div' | 'section' | 'article' | 'blockquote';
className?: string;
}
export function LangBlock({
lang,
children,
as: Component = 'div',
className
}: LangBlockProps) {
const isRtl = RTL_LANGUAGES.some(l => lang.startsWith(l));
return (
<Component
lang={lang}
dir={isRtl ? 'rtl' : 'ltr'}
className={className}
>
{children}
</Component>
);
}
// Usage Example
function MultilingualContent() {
return (
<div>
<p>
The German word <LangSpan lang="de">Schadenfreude</LangSpan> describes
pleasure derived from others' misfortune.
</p>
<LangBlock lang="fr" as="blockquote">
<p>Je pense, donc je suis.</p>
<cite>— René Descartes</cite>
</LangBlock>
<LangBlock lang="ar" as="section">
<h2>مقطع باللغة العربية</h2>
<p>هذا النص مكتوب باللغة العربية.</p>
</LangBlock>
</div>
);
}
Vue 3 Implementation
<script setup lang="ts">
import { ref, computed, watch, onMounted, provide } from 'vue'
// Language composable
const RTL_LANGUAGES = ['ar', 'he', 'fa', 'ur']
export function useLanguage(defaultLanguage = 'en') {
const language = ref(defaultLanguage)
const dir = computed(() =>
RTL_LANGUAGES.some(l => language.value.startsWith(l)) ? 'rtl' : 'ltr'
)
function setLanguage(lang: string) {
language.value = lang
document.documentElement.lang = lang
document.documentElement.dir = RTL_LANGUAGES.some(l => lang.startsWith(l))
? 'rtl'
: 'ltr'
}
onMounted(() => {
document.documentElement.lang = language.value
document.documentElement.dir = dir.value
})
watch(language, (newLang) => {
document.documentElement.lang = newLang
document.documentElement.dir = RTL_LANGUAGES.some(l => newLang.startsWith(l))
? 'rtl'
: 'ltr'
})
return { language, dir, setLanguage }
}
// Using in component
const { language, dir, setLanguage } = useLanguage('en')
// Provide to child components
provide('language', { language, dir, setLanguage })
</script>
<template>
<div>
<!-- Language selector -->
<select @change="setLanguage(($event.target as HTMLSelectElement).value)">
<option value="en">English</option>
<option value="es">Español</option>
<option value="fr">Français</option>
<option value="ar">العربية</option>
</select>
<!-- Inline language change -->
<p>
The French phrase <span lang="fr">c'est la vie</span> means "that's life".
</p>
<!-- Block language change with RTL support -->
<blockquote lang="ar" dir="rtl">
<p>السلام عليكم</p>
</blockquote>
<!-- Section in different language -->
<section lang="es">
<h2>Sección en Español</h2>
<p>Este contenido está completamente en español.</p>
</section>
</div>
</template>
LangSpan Vue Component
<!-- components/LangSpan.vue -->
<script setup lang="ts">
import { computed } from 'vue'
const RTL_LANGUAGES = ['ar', 'he', 'fa', 'ur']
interface Props {
lang: string
tag?: 'span' | 'div' | 'section' | 'article' | 'blockquote' | 'p'
}
const props = withDefaults(defineProps<Props>(), {
tag: 'span'
})
const isRtl = computed(() =>
RTL_LANGUAGES.some(l => props.lang.startsWith(l))
)
const direction = computed(() => isRtl.value ? 'rtl' : undefined)
</script>
<template>
<component
:is="tag"
:lang="lang"
:dir="direction"
>
<slot />
</component>
</template>
<!-- Usage -->
<LangSpan lang="de">Kindergarten</LangSpan>
<LangSpan lang="ja" tag="blockquote">
<p>日本語のテキスト</p>
</LangSpan>
CSS for Language-Specific Styling
/* Base font selections by language */
:lang(en) {
font-family: 'Inter', system-ui, sans-serif;
}
:lang(zh) {
font-family: 'Noto Sans SC', 'PingFang SC', sans-serif;
}
:lang(ja) {
font-family: 'Noto Sans JP', 'Hiragino Sans', sans-serif;
}
:lang(ko) {
font-family: 'Noto Sans KR', 'Apple SD Gothic Neo', sans-serif;
}
:lang(ar) {
font-family: 'Noto Sans Arabic', 'Tahoma', sans-serif;
}
/* RTL language support */
:lang(ar),
:lang(he),
:lang(fa),
:lang(ur) {
direction: rtl;
text-align: right;
}
/* Quotation marks by language */
:lang(en) q {
quotes: '"' '"' "'" "'";
}
:lang(fr) q {
quotes: '« ' ' »' '‹ ' ' ›';
}
:lang(de) q {
quotes: '„' '"' '‚' ''';
}
:lang(ja) q {
quotes: '「' '」' '『' '』';
}
/* Inline foreign phrases styling */
[lang]:not(:lang(en)) {
/* Subtle indication of language change */
font-style: italic;
}
/* Exception: Don't italicize block-level language changes */
section[lang],
article[lang],
blockquote[lang],
div[lang] {
font-style: normal;
}
/* Bidirectional text isolation */
[lang] {
unicode-bidi: isolate;
}
/* Hyphenation by language */
:lang(en) {
hyphens: auto;
-webkit-hyphens: auto;
}
:lang(de) {
/* German has strict hyphenation rules */
hyphens: auto;
-webkit-hyphens: auto;
}
:lang(zh),
:lang(ja),
:lang(ko) {
/* CJK languages don't use hyphenation */
hyphens: none;
word-break: keep-all;
}
Server-Side Language Detection
Next.js/Node.js Implementation
// lib/language.ts
import { NextRequest } from 'next/server';
const SUPPORTED_LANGUAGES = ['en', 'es', 'fr', 'de', 'ar'];
const DEFAULT_LANGUAGE = 'en';
export function detectLanguage(request: NextRequest): string {
// 1. Check URL parameter
const urlLang = request.nextUrl.searchParams.get('lang');
if (urlLang && SUPPORTED_LANGUAGES.includes(urlLang)) {
return urlLang;
}
// 2. Check path prefix
const pathLang = request.nextUrl.pathname.split('/')[1];
if (SUPPORTED_LANGUAGES.includes(pathLang)) {
return pathLang;
}
// 3. Check cookie
const cookieLang = request.cookies.get('preferred-language')?.value;
if (cookieLang && SUPPORTED_LANGUAGES.includes(cookieLang)) {
return cookieLang;
}
// 4. Check Accept-Language header
const acceptLanguage = request.headers.get('accept-language');
if (acceptLanguage) {
const languages = acceptLanguage
.split(',')
.map(lang => {
const [code, q = '1'] = lang.trim().split(';q=');
return { code: code.split('-')[0], quality: parseFloat(q) };
})
.sort((a, b) => b.quality - a.quality);
for (const { code } of languages) {
if (SUPPORTED_LANGUAGES.includes(code)) {
return code;
}
}
}
// 5. Default
return DEFAULT_LANGUAGE;
}
// middleware.ts
export function middleware(request: NextRequest) {
const language = detectLanguage(request);
// Set lang header for SSR
const response = NextResponse.next();
response.headers.set('x-detected-language', language);
return response;
}
Automated Testing
Playwright Tests for Language Attributes
import { test, expect } from '@playwright/test';
test.describe('Language Attributes', () => {
test('page has valid lang attribute on html element', async ({ page }) => {
await page.goto('/');
// Get html element's lang attribute
const lang = await page.locator('html').getAttribute('lang');
// Must exist
expect(lang).not.toBeNull();
expect(lang).not.toBe('');
// Must be valid BCP 47 format (basic check)
expect(lang).toMatch(/^[a-z]{2,3}(-[A-Za-z]{2,4})?(-[A-Za-z]{2})?$/);
});
test('lang attribute matches page content language', async ({ page }) => {
// Test English page
await page.goto('/en/about');
const enLang = await page.locator('html').getAttribute('lang');
expect(enLang).toMatch(/^en/);
// Test Spanish page
await page.goto('/es/about');
const esLang = await page.locator('html').getAttribute('lang');
expect(esLang).toMatch(/^es/);
});
test('foreign phrases have lang attributes', async ({ page }) => {
await page.goto('/multilingual-content');
// Find spans/elements with foreign content
const foreignPhrases = page.locator('[lang]:not(html)');
const count = await foreignPhrases.count();
// If page has foreign content, they should be marked
if (count > 0) {
for (let i = 0; i < count; i++) {
const lang = await foreignPhrases.nth(i).getAttribute('lang');
expect(lang).not.toBeNull();
expect(lang).toMatch(/^[a-z]{2,3}/);
}
}
});
test('RTL languages have correct dir attribute', async ({ page }) => {
await page.goto('/ar/about');
// HTML should have dir="rtl" for Arabic
const dir = await page.locator('html').getAttribute('dir');
expect(dir).toBe('rtl');
// Inline Arabic content should also have dir
const arabicContent = page.locator('[lang^="ar"]');
const count = await arabicContent.count();
for (let i = 0; i < count; i++) {
const element = arabicContent.nth(i);
const isBlock = await element.evaluate(el =>
['DIV', 'SECTION', 'ARTICLE', 'BLOCKQUOTE'].includes(el.tagName)
);
if (isBlock) {
const dir = await element.getAttribute('dir');
expect(dir).toBe('rtl');
}
}
});
test('hreflang attributes are present for alternate languages', async ({ page }) => {
await page.goto('/en/about');
// Check for alternate language links
const alternateLinks = page.locator('link[rel="alternate"][hreflang]');
const count = await alternateLinks.count();
// Should have at least 2 (self + one alternate)
expect(count).toBeGreaterThanOrEqual(2);
// Each should have valid hreflang
for (let i = 0; i < count; i++) {
const hreflang = await alternateLinks.nth(i).getAttribute('hreflang');
expect(hreflang).toMatch(/^[a-z]{2,3}(-[A-Za-z]{2})?$|^x-default$/);
}
});
test('language switcher changes lang attribute', async ({ page }) => {
await page.goto('/en/about');
// Verify initial language
let lang = await page.locator('html').getAttribute('lang');
expect(lang).toMatch(/^en/);
// Click language switcher
await page.click('[data-testid="lang-switch-es"]');
// Wait for navigation
await page.waitForURL(/\/es\//);
// Verify language changed
lang = await page.locator('html').getAttribute('lang');
expect(lang).toMatch(/^es/);
});
});
Accessibility Audit for Language
// language-audit.js
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('Language Accessibility Audit', () => {
test('passes axe language rules', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withRules([
'html-has-lang', // html element must have lang
'html-lang-valid', // lang must be valid
'html-xml-lang-mismatch', // lang and xml:lang must match
'valid-lang' // lang attributes must be valid
])
.analyze();
expect(results.violations).toHaveLength(0);
});
test('all pages have consistent language declaration', async ({ page }) => {
const pages = ['/en/', '/en/about', '/en/services', '/en/contact'];
for (const url of pages) {
await page.goto(url);
const lang = await page.locator('html').getAttribute('lang');
expect(lang).toMatch(/^en/);
// Run axe on each page
const results = await new AxeBuilder({ page })
.withRules(['html-has-lang', 'html-lang-valid'])
.analyze();
expect(results.violations).toHaveLength(0);
}
});
});
Summary Checklist
- [ ] Every page has
langattribute on<html>element - [ ] Language codes follow BCP 47 format
- [ ] Language matches actual page content
- [ ] Foreign phrases have appropriate
langattributes - [ ] RTL languages have
dir="rtl"attribute - [ ] CSS provides language-specific typography settings
- [ ] Alternate language versions have
hreflanglinks - [ ] Language detection uses proper fallback chain
- [ ] Components support dynamic language switching
- [ ] Automated tests verify language attributes
Related Articles
- Semantic HTML Guide - Document structure
- WCAG Compliance Hub - All accessibility evaluators
References
- W3C - WCAG 2.2 SC 3.1.1 Language of Page
- W3C - WCAG 2.2 SC 3.1.2 Language of Parts
- W3C - H57 Using lang attribute
- MDN - lang attribute
- IETF - BCP 47: Tags for Identifying Languages
- W3C - Choosing language tags