View contents
Introduction
Proper language identification is fundamental to web accessibility. This guide covers complete implementation patterns for the lang attribute, BCP 47 language tags, multilingual content handling, and framework-specific approaches that meet WCAG 3.1.1 and 3.1.2 requirements.
Beyond accessibility, correct language attributes enable proper spell-checking, hyphenation, font selection, and search engine optimization for multilingual content.
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