Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Reference-grade guide to building locale-ready UIs — i18n vs l10n vs translation, text expansion, RTL/bidi with CSS logical properties and :dir(), locale formatting via Intl, ICU MessageFormat pluralization, script typography, cultural imagery, and a real translation workflow with pseudolocalization.
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✓→✓ | = Same ✓ | 118% | 0% |
| case-03 | ✓→✓ | = Same ✓ | 253% | 0% |
| case-04 | ✓→✓ | = Same ✓ | 222% | 0% |
| case-05 | ✓→✓ | = Same ✓ | 303% | 0% |
| case-06 | ✓→✓ | = Same ✓ | 269% | 0% |
Designing for one language and bolting on translation later is the most expensive shortcut in product. Layouts crack under German, hardcoded MM/DD/YYYY lies to half the planet, "You have " + count + " items" can't pluralize in any language but English, and a left-pinned sidebar lands on the wrong edge in Arabic. Build for many locales from the first commit.
| Term | What it is | Who owns it | When | |------|-----------|-------------|------| | Internationalization (i18n) | Engineering the product so it can support any locale: externalized strings, locale-aware formatting, logical layout, RTL support, font fallback | Engineers + designers | Build time, once | | Localization (l10n) | Adapting the product to a specific locale: translated copy, local formats, culturally-fit imagery, legal compliance | Localizers + translators + designers | Per target locale | | Translation | Converting text from one language to another | Translators | A subtask of l10n |
> i18n = "18 letters between i and n". l10n = "10 between l and n". i18n is the foundation that makes l10n cheap. Translation alone is not localization — translated text in an LTR-only layout with US date formats is still broken.
A locale is more than a language. It's language-REGION (BCP 47): en-US ≠ en-GB (color/colour, MM/DD vs DD/MM), pt-BR ≠ pt-PT, zh-Hans (Simplified) ≠ zh-Hant (Traditional). Region drives formatting; language drives translation. Always carry the full tag.
Translated text changes length. UI built tight around English overflows, truncates, or wraps badly.
| Source EN length | Typical expansion | Example | |------------------|-------------------|---------| | 1–10 chars | up to 200–300% | "View" → "Anzeigen", "Show" → "Visualizza" | | 11–20 chars | up to 180% | German compounds run long | | 21–30 chars | up to 160% | | | 50+ chars | up to 130% | German averages +35%; Finnish, Russian similar |
CJK and some scripts shrink (fewer characters) but need taller line boxes. Plan for both directions.
Rules:
min-width not width. Allow wrapping or set sensible max-width.<text> you can swap per locale.css/* DON'T — English-tuned, clips other locales */ .btn { width: 96px; height: 32px; white-space: nowrap; overflow: hidden; } /* DO — content-sized, room to grow */ .btn { min-width: 6ch; padding-block: 0.5rem; padding-inline: 1rem; white-space: normal; /* allow wrap if needed */ overflow-wrap: break-word; }
Arabic, Hebrew, Persian (Farsi), Urdu, and others read right to left. The whole layout mirrors — not just text alignment. Numbers inside RTL text still run left-to-right (bidi).
This is the single highest-leverage RTL technique. Logical properties resolve direction at runtime; physical ones don't.
| Physical (avoid) | Logical (use) | Mirrors in RTL? | |------------------|---------------|-----------------| | margin-left / margin-right | margin-inline-start / margin-inline-end | yes | | padding-left | padding-inline-start | yes | | left / right (insets) | inset-inline-start / inset-inline-end | yes | | text-align: left | text-align: start | yes | | border-left | border-inline-start | yes | | width / height | inline-size / block-size | direction-aware | | float: left | float: inline-start | yes |
css/* DON'T — hardcoded, breaks in RTL */ .card { margin-left: 16px; padding-right: 12px; text-align: left; border-left: 2px solid; } /* DO — flips automatically with dir="rtl" */ .card { margin-inline-start: 16px; padding-inline-end: 12px; text-align: start; border-inline-start: 2px solid; }
Flexbox and Grid already follow direction — flex-direction: row reverses, justify-content: flex-start becomes the start edge. Prefer them over absolute positioning.
:dir()html<html lang="ar" dir="rtl"> <!-- or dir="auto" to infer from first strong char -->
css/* Modern: style by resolved direction without an attribute selector */ .icon-chevron { transform: scaleX(1); } :dir(rtl) .icon-chevron { transform: scaleX(-1); } /* flip the arrow */ /* Fallback for older engines */ [dir="rtl"] .icon-chevron { transform: scaleX(-1); }
:dir() reflects the resolved direction (including dir="auto"), which [dir="rtl"] cannot. Provide the attribute-selector fallback until Safari support is universal in your matrix.
| Mirrors (flip in RTL) | Stays the same | |-----------------------|----------------| | Page layout, columns, nav order | Brand logos and logotypes | | Back/forward, next/prev arrows | Numbers and number-shaped data | | Progress bars, sliders, carousels direction | Clocks / analog time | | Breadcrumb chevrons, dropdown carets | Media controls (play ▶ always points to playhead direction = right) | | List bullets, checkmark alignment | Phone keypads, calculators | | Icons implying direction (reply, undo, send) | Icons with no direction (search, settings, heart) | | Text and form field alignment | Photographs of real scenes, maps |
When unsure, ask: does this icon imply forward motion or sequence? If yes, mirror it. A "play" triangle does not mirror because it points toward the timeline's advancing edge (which is also why media scrubbers don't flip).
When you interpolate user data (a name, a URL) into directional text, the bidirectional algorithm can reorder neighboring characters. Isolate untrusted runs.
html<!-- A Hebrew filename inside an English sentence can scramble punctuation --> <p>Downloaded <bdi>קובץ.pdf</bdi> successfully.</p>
css.user-content { unicode-bidi: isolate; } /* CSS equivalent of <bdi> */
In JS, wrap with Unicode isolates … (FSI/PDI) or use Intl which handles it. Never concatenate directional fragments without isolation.
Intl APINever hardcode formats. Intl reads Unicode CLDR data — the authoritative locale database — so the browser/runtime does the locale-correct thing. It covers dates, numbers, currency, units, lists, relative time, plurals, and collation.
Order, separators, month names, era, and calendar system vary. 01/02/2026 is Jan 2 in the US and Feb 1 in the UK.
jsnew Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format(d); // Jun 5, 2026 new Intl.DateTimeFormat('en-GB', { dateStyle: 'medium' }).format(d); // 5 Jun 2026 new Intl.DateTimeFormat('ja-JP', { dateStyle: 'medium' }).format(d); // 2026/06/05 new Intl.DateTimeFormat('ar-EG', { dateStyle: 'long' }).format(d); // ٥ يونيو ٢٠٢٦ // Non-Gregorian calendars (Islamic, Buddhist, Hebrew, Japanese eras) new Intl.DateTimeFormat('ar-SA-u-ca-islamic', { dateStyle: 'long' }).format(d); new Intl.DateTimeFormat('th-TH-u-ca-buddhist', { dateStyle: 'long' }).format(d); // 2569 // Time zones — store UTC, format per user zone. NEVER assume server zone. new Intl.DateTimeFormat('en-US', { timeStyle: 'short', timeZone: 'Asia/Tokyo' }).format(d);
Intl pick (hourCycle to override). Most of the world uses 24h; the US/UK use 12h with AM/PM.Intl.RelativeTimeFormat for "3 days ago" / "in 2 hours" instead of building strings.jsnew Intl.NumberFormat('en-US').format(1234567.89); // 1,234,567.89 (comma group, dot decimal) new Intl.NumberFormat('de-DE').format(1234567.89); // 1.234.567,89 (dot group, comma decimal) new Intl.NumberFormat('fr-FR').format(1234567.89); // 1 234 567,89 (thin-space group) new Intl.NumberFormat('hi-IN').format(1234567.89); // 12,34,567.89 (Indian lakh grouping!) // Currency — symbol, placement, and spacing all vary new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(9.9); // $9.90 new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(9.9); // 9,90 € new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY' }).format(990); // ¥990 (no minor units) // Units & lists new Intl.NumberFormat('en-US', { style: 'unit', unit: 'kilometer-per-hour' }).format(80); // 80 km/h new Intl.ListFormat('en', { type: 'conjunction' }).format(['A','B','C']); // A, B, and C
> Decimal/grouping separators invert between en and de. A naive parseFloat on de-DE input ("1.234,56") reads 1.234. Parse with locale awareness, or store machine numbers (.-decimal, no grouping) and only format for display.
English has 2 plural forms (1 / other). Arabic has 6 (zero, one, two, few, many, other). Russian, Polish, Welsh have intricate rules. You cannot "add an s."
zero · one · two · few · many · other — not every language uses all six; other is always required as the fallback.
jsnew Intl.PluralRules('en').select(1); // 'one' new Intl.PluralRules('en').select(2); // 'other' new Intl.PluralRules('ar').select(0); // 'zero' new Intl.PluralRules('ar').select(11); // 'many' new Intl.PluralRules('pl').select(3); // 'few'
Don't concatenate sentence fragments — word order and agreement differ per language, and translators can't reorder fragments. Put the whole message in one ICU string with placeholders, and let the formatter pick the branch.
You have {count, plural,
=0 {no new messages}
one {# new message}
other {# new messages}
}.{gender, select,
female {She updated her profile}
male {He updated his profile}
other {They updated their profile}
}Nested plural + select + interpolation works too. Use a library that speaks ICU: intl-messageformat, @formatjs/intl (FormatJS), i18next (ICU plugin), Lingui, or Fluent (Mozilla's superset).
# is the locale-formatted number inside plural.=0 exact-matches before category rules, for natural copy ("no items").t('greeting', { name }) not t('hello') + name.Hardcoding "First name / Last name" is a Western assumption that fails most of the world.
+<country><number>), accept varied input, format for display with a locale-aware library (libphonenumber). Don't hardcode a 3-3-4 mask.Latin defaults break other writing systems. Match font, metrics, and rendering to the script.
| Script | Watch for | |--------|-----------| | CJK (中文 / 日本語 / 한국어) | Larger effective size; increase line-height (≈1.7–2.0) — glyphs are dense. No faux bold/italic — synthesize nothing; ship real weights or skip emphasis. Allow line breaks between any characters (line-break, word-break: normal). Support vertical text (writing-mode: vertical-rl) where the design calls for it. | | Arabic | Connected (cursive) script — letters change shape by position; never letter-space or break joins. Render larger (Latin 16px ≈ Arabic 18–20px) and looser line-height for legibility. RTL. No faux styling. | | Indic (Devanagari, Tamil, Bengali…) | Complex shaping, stacked conjuncts, above/below-baseline marks; needs a proper OpenType shaper and generous line-height. Don't clip ascenders/descenders. | | Thai/Lao/Khmer | No spaces between words; line-breaking needs dictionary support. Tall stacked marks. |
css/* Per-script font fallback stacks, switched by lang */ :root { --font-latin: "Inter", system-ui, sans-serif; } :lang(ja) { --font: "Noto Sans JP", var(--font-latin); line-height: 1.8; } :lang(ar) { --font: "Noto Sans Arabic", var(--font-latin); font-size: 1.08em; } :lang(zh-Hans) { --font: "Noto Sans SC", var(--font-latin); line-height: 1.75; } body, [lang] { font-family: var(--font, var(--font-latin)); }
unicode-range to load only needed ranges and ship one variable font per script rather than many static weights.css@font-face { font-family: "Noto Sans SC"; src: url("/fonts/noto-sc-var.woff2") format("woff2-variations"); font-weight: 100 900; /* variable axis */ unicode-range: U+4E00-9FFF, U+3000-303F; /* load CJK ranges only */ font-display: swap; }
Visual meaning is not universal — localize it like text.
Treat strings as content with a pipeline, not literals in code.
checkout.button.submit, not "Place order". English-as-key breaks when the source copy changes and collides when the same English means two things.Edit → [Éḋíţ ⟦padding⟧][…] to reveal clipped/concatenated strings.| Don't | Do | |-------|-----| | Concatenate sentence fragments (t('you_have') + n + t('items')) | One ICU message with placeholders + plural/select | | Hardcode date/number/currency formats | Intl.DateTimeFormat / NumberFormat (CLDR) | | Bake text into images/sprites | Live text or SVG <text> swappable per locale | | Fixed-width buttons/labels | Content-sized with room for +35% expansion | | margin-left / text-align: left | margin-inline-start / text-align: start | | Assume LTR everywhere | dir/:dir(), logical props, test an RTL pseudo-locale | | Use a flag for a language | Language name in its own script | | First name / Last name required | Single "Full name"; don't assume order or gender | | "Add an s" pluralization | Intl.PluralRules + ICU plural (6 categories) | | Key strings by English text | Semantic keys + translator context + screenshots | | Faux bold/italic on CJK/Arabic | Real font weights; size/weight/color for emphasis | | Assume server time zone | Store UTC, format in the user's timeZone |
Intl; nothing hardcoded.Intl.PluralRules + ICU MessageFormat, never concatenation.dir="rtl" and an RTL pseudo-locale.Other measured skills in the registry, with their headline benchmark lift.