Detailed guide

Language Attributes Implementation Guide

View contents

Language Attributes Implementation Guide: Complete Patterns for Multilingual Web Accessibility

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 lang attribute on <html> element
  • [ ] Language codes follow BCP 47 format
  • [ ] Language matches actual page content
  • [ ] Foreign phrases have appropriate lang attributes
  • [ ] RTL languages have dir="rtl" attribute
  • [ ] CSS provides language-specific typography settings
  • [ ] Alternate language versions have hreflang links
  • [ ] Language detection uses proper fallback chain
  • [ ] Components support dynamic language switching
  • [ ] Automated tests verify language attributes

References

  1. W3C - WCAG 2.2 SC 3.1.1 Language of Page
  2. W3C - WCAG 2.2 SC 3.1.2 Language of Parts
  3. W3C - H57 Using lang attribute
  4. IETF - BCP 47: Tags for Identifying Languages
  5. W3C - Choosing language tags

Related articles

Related version

Introduction

Language Attributes Explained

The HTML attribute tells browsers and assistive technologies what human language your content is in

Category hub

Hub

Wcag Compliance Hub

Web accessibility ensures that websites and applications can be used by everyone, including people with disabilities