Internationalization
Internationalization (i18n) is the process of designing applications that can adapt to various languages, scripts, and cultural conventions without code changes. HTML provides built-in mechanisms — the lang and dir attributes, meta charset, and elements like <time> — that form the foundation of a globally accessible web.
Proper i18n ensures content is correctly rendered by browsers, properly indexed by search engines, and accurately announced by screen readers. It also enables localization (l10n) — the translation and cultural adaptation of content for specific markets.
The lang attribute declares the language of an element's content. It uses BCP 47 language tags — a language subtag (e.g., en for English, ar for Arabic) optionally followed by a region subtag (e.g., en-US, en-GB). The attribute is inherited, so setting it on <html> covers the entire document.
| Language Tag | Language | Region |
|---|---|---|
| en-US | English | United States |
| en-GB | English | Great Britain |
| zh-CN | Chinese (Simplified) | China |
| zh-TW | Chinese (Traditional) | Taiwan |
| es-MX | Spanish | Mexico |
| pt-BR | Portuguese | Brazil |
| ar-SA | Arabic | Saudi Arabia |
| ja-JP | Japanese | Japan |
| fr-CA | French | Canada |
| de-DE | German | Germany |
| 1 | <!-- Document-level language declaration --> |
| 2 | <!DOCTYPE html> |
| 3 | <html lang="en-US"> |
| 4 | <head> |
| 5 | <meta charset="UTF-8" /> |
| 6 | <title>My Website</title> |
| 7 | </head> |
| 8 | <body> |
| 9 | <!-- Inline language override for a foreign phrase --> |
| 10 | <p> |
| 11 | The quick brown fox jumps over the lazy dog. |
| 12 | <span lang="fr">C'est la vie.</span> |
| 13 | The show must go on. |
| 14 | </p> |
| 15 | |
| 16 | <!-- Language with region subtag --> |
| 17 | <p lang="en-GB"> |
| 18 | The boot is full of petrol and the lorry is broken. |
| 19 | </p> |
| 20 | |
| 21 | <!-- Chinese text with correct language --> |
| 22 | <p lang="zh-CN"> |
| 23 | 国际化为全球用户提供更好的体验。 |
| 24 | </p> |
| 25 | |
| 26 | <!-- Arabic text — note dir="rtl" is also needed --> |
| 27 | <p lang="ar" dir="rtl"> |
| 28 | مرحبا بالعالم |
| 29 | </p> |
| 30 | </body> |
| 31 | </html> |
best practice
The dir attribute specifies the text direction of an element's content: ltr (left-to-right, default), rtl (right-to-left for Arabic, Hebrew, Persian), or auto (browser detects direction from content). Like lang, it is inherited and can be overridden on any element.
| Value | Direction | Typical Scripts |
|---|---|---|
| ltr | Left to Right | Latin, Cyrillic, Greek, CJK |
| rtl | Right to Left | Arabic, Hebrew, Persian, Urdu |
| auto | Auto-detect | User-generated content with unknown direction |
| 1 | <!-- Document set to RTL for Arabic --> |
| 2 | <html lang="ar" dir="rtl"> |
| 3 | <head> |
| 4 | <meta charset="UTF-8" /> |
| 5 | <title>موقعي</title> |
| 6 | </head> |
| 7 | <body> |
| 8 | <h1>مرحبا بالعالم</h1> |
| 9 | <p>هذا هو موقعي الشخصي على الإنترنت.</p> |
| 10 | |
| 11 | <!-- Mixed direction: English phrase within Arabic text --> |
| 12 | <p> |
| 13 | يمكنك زيارة |
| 14 | <span dir="ltr">example.com</span> |
| 15 | للمزيد من المعلومات. |
| 16 | </p> |
| 17 | |
| 18 | <!-- Auto direction for user-generated content --> |
| 19 | <div dir="auto"> |
| 20 | <!-- Browser detects direction from first strong character --> |
| 21 | مرحبا |
| 22 | </div> |
| 23 | <div dir="auto"> |
| 24 | Hello there |
| 25 | </div> |
| 26 | </body> |
| 27 | </html> |
| 28 | |
| 29 | <!-- CSS logical properties respect dir --> |
| 30 | <style> |
| 31 | .card { |
| 32 | margin-inline-start: 16px; |
| 33 | padding-inline: 12px; |
| 34 | border-inline-start: 2px solid #00FF41; |
| 35 | } |
| 36 | </style> |
Live preview showing RTL text handling alongside LTR content:
pro tip
Character encoding defines how characters are represented as bytes. UTF-8 (Unicode Transformation Format – 8-bit) is the universal standard, encoding over a million characters from every script in use today. The meta charset element declares the encoding to the browser before any text content is parsed.
| 1 | <!-- Always use UTF-8 — the first line after <head> --> |
| 2 | <!DOCTYPE html> |
| 3 | <html lang="en"> |
| 4 | <head> |
| 5 | <meta charset="UTF-8" /> |
| 6 | <title>Unicode Support: ñ, ü, 你, あ, ደማቅ</title> |
| 7 | </head> |
| 8 | <body> |
| 9 | <p>UTF-8 handles all scripts seamlessly:</p> |
| 10 | <ul> |
| 11 | <li>Latin: ñ, ü, à, ç, ø, ð</li> |
| 12 | <li>Greek: α, β, γ, δ, ε</li> |
| 13 | <li>Cyrillic: б, в, г, д, ж</li> |
| 14 | <li>CJK: 你好世界 (Chinese)</li> |
| 15 | <li>Japanese: こんにちは (Hiragana)</li> |
| 16 | <li>Arabic: مرحبا (Arabic)</li> |
| 17 | <li>Emoji: 🚀 🌍 🎉</li> |
| 18 | </ul> |
| 19 | |
| 20 | <!-- HTML entities for reserved characters --> |
| 21 | <p>Use &lt; for < and &gt; for >.</p> |
| 22 | <p>Use &amp; for & itself.</p> |
| 23 | <p>Non-breaking space: 100&nbsp;kg</p> |
| 24 | </body> |
| 25 | </html> |
| 26 | |
| 27 | <!-- Wrong: legacy encoding declaration --> |
| 28 | <meta charset="ISO-8859-1" /> |
| 29 | <!-- This will mangle non-Latin characters --> |
warning
Browsers send an Accept-Language HTTP header with every request, listing the user's preferred languages and their relative priority (quality values). Servers use this header to serve language-specific content — a process called content negotiation. The navigator.language JavaScript API provides read-only access to the user's preferred language on the client side.
| 1 | // Accept-Language HTTP header (sent by browser) |
| 2 | Accept-Language: en-US,en;q=0.9,fr;q=0.8,ar;q=0.7 |
| 3 | |
| 4 | // Weighted preferences: |
| 5 | // en-US — primary language (default weight 1.0) |
| 6 | // en — fallback English (weight 0.9) |
| 7 | // fr — French (weight 0.8) |
| 8 | // ar — Arabic (weight 0.7) |
| 9 | |
| 10 | // Server-side negotiation (Node.js example) |
| 11 | app.get('/', (req, res) => { |
| 12 | const lang = req.acceptsLanguages(['en', 'fr', 'ar', 'zh']); |
| 13 | // Respond with content in the best-matching language |
| 14 | res.render(`index-${lang}`); |
| 15 | }); |
| 16 | |
| 17 | // Client-side language detection |
| 18 | const userLang = navigator.language; // "en-US" |
| 19 | const userLangs = navigator.languages; // ["en-US", "en", "fr"] |
| 20 | const isRTL = /^(ar|he|fa|ur)/.test(userLang); |
info
The <time> element marks up dates, times, or durations in a machine-readable format while displaying a human-readable version. The datetime attribute uses the ISO 8601 standard (YYYY-MM-DD, HH:MM, YYYY-MM-DDTHH:MM:SSZ). This enables calendar integration, search engine snippets, and locale-aware formatting via JavaScript.
| 1 | <!-- Date only --> |
| 2 | <time datetime="2026-07-07">July 7, 2026</time> |
| 3 | |
| 4 | <!-- Time with timezone --> |
| 5 | <time datetime="2026-07-07T14:30:00Z"> |
| 6 | 2:30 PM UTC |
| 7 | </time> |
| 8 | |
| 9 | <!-- Time with offset --> |
| 10 | <time datetime="2026-07-07T10:30:00-04:00"> |
| 11 | 10:30 AM Eastern |
| 12 | </time> |
| 13 | |
| 14 | <!-- Duration --> |
| 15 | <time datetime="PT2H30M">2 hours 30 minutes</time> |
| 16 | |
| 17 | <!-- Week --> |
| 18 | <time datetime="2026-W28">Week 28 of 2026</time> |
| 19 | |
| 20 | <!-- Month --> |
| 21 | <time datetime="2026-07">July 2026</time> |
| 22 | |
| 23 | <!-- Locale-aware display via JavaScript --> |
| 24 | <time datetime="2026-07-07" class="locale-date"> |
| 25 | July 7, 2026 |
| 26 | </time> |
| 27 | |
| 28 | <script> |
| 29 | // Convert to user's locale |
| 30 | document.querySelectorAll('.locale-date').forEach(el => { |
| 31 | const date = new Date(el.getAttribute('datetime')); |
| 32 | el.textContent = date.toLocaleDateString( |
| 33 | navigator.language, |
| 34 | { year: 'numeric', month: 'long', day: 'numeric' } |
| 35 | ); |
| 36 | }); |
| 37 | </script> |
| 38 | |
| 39 | <!-- Multiple locales shown --> |
| 40 | <ul> |
| 41 | <li><time datetime="2026-07-07" class="locale-full">Jul 7, 2026</time></li> |
| 42 | </ul> |
| 43 | <script> |
| 44 | const locales = ['en-US', 'fr-FR', 'de-DE', 'ja-JP', 'ar-SA']; |
| 45 | document.querySelectorAll('.locale-full').forEach(el => { |
| 46 | const d = new Date(el.getAttribute('datetime')); |
| 47 | el.textContent = locales |
| 48 | .map(l => `${l}: ${d.toLocaleDateString(l, { year:'numeric', month:'long', day:'numeric' })}`) |
| 49 | .join(' | '); |
| 50 | }); |
| 51 | </script> |
Live preview showing date formatting across different locales:
Numbers, currencies, and percentages must be formatted according to regional conventions. JavaScript's Intl.NumberFormat API handles this natively — decimal separators, digit grouping, currency symbols, and percent notation vary dramatically across locales. HTML alone cannot localize numbers; the formatting must be done on the server or via client-side JavaScript.
| 1 | <!-- Raw numeric data — no formatting in HTML --> |
| 2 | <output id="price" data-value="1234567.89">$1,234,567.89</output> |
| 3 | |
| 4 | <!-- JavaScript locale-aware formatting --> |
| 5 | <script> |
| 6 | const value = 1234567.89; |
| 7 | |
| 8 | // Currency formatting per locale |
| 9 | const formats = [ |
| 10 | { locale: 'en-US', style: 'currency', currency: 'USD' }, |
| 11 | { locale: 'de-DE', style: 'currency', currency: 'EUR' }, |
| 12 | { locale: 'ja-JP', style: 'currency', currency: 'JPY' }, |
| 13 | { locale: 'ar-SA', style: 'currency', currency: 'SAR' }, |
| 14 | { locale: 'en-IN', style: 'currency', currency: 'INR' }, |
| 15 | ]; |
| 16 | |
| 17 | const rows = formats.map(f => |
| 18 | `${f.locale}: ${new Intl.NumberFormat( |
| 19 | f.locale, f |
| 20 | ).format(value)}` |
| 21 | ); |
| 22 | console.log(rows); |
| 23 | // en-US: $1,234,567.89 |
| 24 | // de-DE: 1.234.567,89 € |
| 25 | // ja-JP: ¥1,234,568 |
| 26 | // ar-SA: ١٬٢٣٤٬٥٦٧٫٨٩ ر.س. |
| 27 | // en-IN: ₹12,34,567.89 |
| 28 | |
| 29 | // Percentage formatting |
| 30 | const pct = new Intl.NumberFormat('en-US', { |
| 31 | style: 'percent', |
| 32 | maximumFractionDigits: 1, |
| 33 | }).format(0.856); // "85.6%" |
| 34 | |
| 35 | // Compact notation |
| 36 | const compact = new Intl.NumberFormat('en-US', { |
| 37 | notation: 'compact', |
| 38 | compactDisplay: 'short', |
| 39 | }).format(1250000); // "1.3M" |
| 40 | </script> |
pro tip
Pluralization rules vary dramatically across languages. English has two forms (singular: "1 item", plural: "2 items"). Arabic has six forms (singular, dual, few, many, plural, zero). Russian and Polish have complex rules based on the last digits. JavaScript's Intl.PluralRules API determines which plural category a number belongs to for a given locale.
| Language | Categories | Example |
|---|---|---|
| English | one, other | 1 item, 2 items |
| Arabic | zero, one, two, few, many, other | 0, 1, 2, 3-10, 11-99, 100+ |
| Russian | one, few, many, other | 1, 2-4, 5-20, 21+ |
| Japanese | other | No plural distinction |
| Chinese | other | No plural distinction |
| 1 | <!-- Pluralization with Intl.PluralRules --> |
| 2 | <script> |
| 3 | function pluralize(locale, count, forms) { |
| 4 | const rules = new Intl.PluralRules(locale); |
| 5 | const category = rules.select(count); |
| 6 | return forms[category] || forms.other; |
| 7 | } |
| 8 | |
| 9 | // English: two forms |
| 10 | const enForms = { |
| 11 | one: `${count} item`, |
| 12 | other: `${count} items`, |
| 13 | }; |
| 14 | |
| 15 | // Arabic: six forms |
| 16 | const arForms = { |
| 17 | zero: 'لا عناصر', |
| 18 | one: 'عنصر واحد', |
| 19 | two: 'عنصران', |
| 20 | few: `${count} عناصر`, |
| 21 | many: `${count} عنصراً`, |
| 22 | other: `${count} عنصر`, |
| 23 | }; |
| 24 | |
| 25 | // Russian: four forms |
| 26 | const ruForms = { |
| 27 | one: `${count} элемент`, |
| 28 | few: `${count} элемента`, |
| 29 | many: `${count} элементов`, |
| 30 | other: `${count} элемента`, |
| 31 | }; |
| 32 | |
| 33 | // Usage |
| 34 | console.log(pluralize('en', 1, enForms)); // "1 item" |
| 35 | console.log(pluralize('en', 5, enForms)); // "5 items" |
| 36 | console.log(pluralize('ar', 0, arForms)); // "لا عناصر" |
| 37 | console.log(pluralize('ar', 2, arForms)); // "عنصران" |
| 38 | console.log(pluralize('ru', 3, ruForms)); // "3 элемента" |
| 39 | console.log(pluralize('ru', 21, ruForms)); // "21 элемент" |
| 40 | </script> |
best practice
Localized strings are stored in message catalogs — key-value pairs where the key is a semantic identifier and the value is the translated text. These catalogs are typically organized as JSON files, one per locale. Variables, formatting, and pluralization are handled through interpolation and ICU message syntax.
| 1 | // en.json — English message catalog |
| 2 | { |
| 3 | "greeting": "Hello, {name}!", |
| 4 | "welcome": "Welcome to our platform", |
| 5 | "items_count": "{count} item(s)", |
| 6 | "items_count_plural": { |
| 7 | "one": "{count} item", |
| 8 | "other": "{count} items" |
| 9 | }, |
| 10 | "notification": "You have {unread, plural, one {# unread message} other {# unread messages}}", |
| 11 | "date_format": "{date, date, long}", |
| 12 | "temperature": "{value}°{unit}" |
| 13 | } |
| 14 | |
| 15 | // fr.json — French message catalog |
| 16 | { |
| 17 | "greeting": "Bonjour, {name} !", |
| 18 | "welcome": "Bienvenue sur notre plateforme", |
| 19 | "items_count_plural": { |
| 20 | "one": "{count} élément", |
| 21 | "other": "{count} éléments" |
| 22 | }, |
| 23 | "notification": "Vous avez {unread, plural, one {# message non lu} other {# messages non lus}}", |
| 24 | "temperature": "{value} °{unit}" |
| 25 | } |
| 26 | |
| 27 | // ar.json — Arabic message catalog |
| 28 | { |
| 29 | "greeting": "مرحبا، {name}", |
| 30 | "welcome": "مرحباً بك في منصتنا", |
| 31 | "items_count_plural": { |
| 32 | "zero": "لا توجد عناصر", |
| 33 | "one": "عنصر واحد", |
| 34 | "two": "عنصران", |
| 35 | "few": "{count} عناصر", |
| 36 | "many": "{count} عنصراً", |
| 37 | "other": "{count} عنصر" |
| 38 | }, |
| 39 | "temperature": "{value}°{unit}" |
| 40 | } |
| 1 | <!-- HTML template with localized strings (JS example) --> |
| 2 | <!-- The actual rendering happens via a template engine or JS --> |
| 3 | |
| 4 | <!-- Data attributes approach for static HTML --> |
| 5 | <div |
| 6 | data-i18n="greeting" |
| 7 | data-i18n-params='{"name": "Alice"}' |
| 8 | > |
| 9 | Hello, Alice! |
| 10 | </div> |
| 11 | |
| 12 | <!-- With a minimal i18n script --> |
| 13 | <script> |
| 14 | const i18n = { |
| 15 | en: { greeting: 'Hello, {name}!' }, |
| 16 | fr: { greeting: 'Bonjour, {name} !' }, |
| 17 | ar: { greeting: 'مرحبا، {name}' }, |
| 18 | }; |
| 19 | |
| 20 | function t(key, params = {}) { |
| 21 | const lang = document.documentElement.lang || 'en'; |
| 22 | let msg = i18n[lang]?.[key] || key; |
| 23 | for (const [k, v] of Object.entries(params)) { |
| 24 | msg = msg.replace(`{${k}}`, v); |
| 25 | } |
| 26 | return msg; |
| 27 | } |
| 28 | |
| 29 | // Translate all data-i18n elements |
| 30 | document.querySelectorAll('[data-i18n]').forEach(el => { |
| 31 | const key = el.dataset.i18n; |
| 32 | const params = JSON.parse(el.dataset.i18nParams || '{}'); |
| 33 | el.textContent = t(key, params); |
| 34 | }); |
| 35 | </script> |
| 36 | |
| 37 | <!-- For production: use a library like i18next --> |
| 38 | <script src="https://unpkg.com/i18next/i18next.min.js"></script> |
| 39 | <script> |
| 40 | i18next.init({ |
| 41 | lng: 'fr', |
| 42 | resources: { |
| 43 | fr: { translation: { welcome: 'Bienvenue' } } |
| 44 | } |
| 45 | }); |
| 46 | document.getElementById('title').textContent = |
| 47 | i18next.t('welcome'); |
| 48 | </script> |
The hreflang attribute tells search engines about alternate language versions of a page. It is used in <link> elements within <head> or as an attribute on <a> links. This helps Google and other search engines serve the correct language version to users in search results.
| 1 | <!-- Language alternates in <head> --> |
| 2 | <head> |
| 3 | <link rel="alternate" hreflang="en" href="https://example.com/" /> |
| 4 | <link rel="alternate" hreflang="fr" href="https://example.com/fr/" /> |
| 5 | <link rel="alternate" hreflang="ar" href="https://example.com/ar/" /> |
| 6 | <link rel="alternate" hreflang="ja" href="https://example.com/ja/" /> |
| 7 | <link rel="alternate" hreflang="zh" href="https://example.com/zh/" /> |
| 8 | |
| 9 | <!-- x-default for language-agnostic or fallback page --> |
| 10 | <link rel="alternate" hreflang="x-default" href="https://example.com/" /> |
| 11 | |
| 12 | <!-- Region-specific: en-US vs en-GB --> |
| 13 | <link rel="alternate" hreflang="en-US" href="https://example.com/us/" /> |
| 14 | <link rel="alternate" hreflang="en-GB" href="https://example.com/uk/" /> |
| 15 | </head> |
| 16 | |
| 17 | <!-- Inline hreflang on links --> |
| 18 | <a href="/fr/" hreflang="fr" lang="fr"> |
| 19 | Version française |
| 20 | </a> |
| 21 | <a href="/ar/" hreflang="ar" lang="ar" dir="rtl"> |
| 22 | النسخة العربية |
| 23 | </a> |
| 24 | |
| 25 | <!-- Self-referencing hreflang is mandatory --> |
| 26 | <!-- Each language variant must link to all others, including itself --> |
| 27 | <head> |
| 28 | <!-- On the English page: --> |
| 29 | <link rel="alternate" hreflang="en" href="https://example.com/en/" /> |
| 30 | <link rel="alternate" hreflang="fr" href="https://example.com/fr/" /> |
| 31 | </head> |
| 32 | <!-- On the French page: --> |
| 33 | <head> |
| 34 | <!-- Must also link to both: --> |
| 35 | <link rel="alternate" hreflang="en" href="https://example.com/en/" /> |
| 36 | <link rel="alternate" hreflang="fr" href="https://example.com/fr/" /> |
| 37 | </head> |
| 38 | |
| 39 | <!-- Alternate approach: HTML sitemap or HTTP headers --> |
| 40 | <!-- Link: <https://example.com/en/>; rel="alternate"; hreflang="en" --> |
| 41 | |
| 42 | <!-- Language switcher with hreflang --> |
| 43 | <nav aria-label="Language switcher"> |
| 44 | <ul> |
| 45 | <li><a href="/en/" hreflang="en" lang="en" aria-current="page">English</a></li> |
| 46 | <li><a href="/fr/" hreflang="fr" lang="fr">Français</a></li> |
| 47 | <li><a href="/ar/" hreflang="ar" lang="ar" dir="rtl">العربية</a></li> |
| 48 | </ul> |
| 49 | </nav> |
warning