Localising Extension UI with the i18n API

Build a _locales bundle that survives translation: placeholders instead of concatenation, the locale fallback chain, plural handling, and a CSP-safe DOM translation pass.

Published August 1, 2026 Updated August 1, 2026 9 min read
Table of Contents

The German translation comes back and the popup is broken: a button that read “Save” now reads “Einstellungen speichern” and has pushed the close control off the edge, while a sentence assembled from three concatenated strings reads as nonsense because German puts the verb last. Neither problem is a translation error. Both are consequences of how the strings were structured before they were sent out. This page is part of Internationalization & Accessibility and covers building a message bundle that a translator can actually work with, and a translation pass that runs under the extension content security policy.

Root cause: extension HTML is never substituted for you

__MSG_name__ works in manifest.json and in CSS files, and nowhere else. Your popup, options page and side panel are ordinary HTML documents that the browser hands to you untouched, so every visible string has to be replaced by script at runtime — from an external file, because inline <script> is refused. That constraint shapes the whole approach: mark up the nodes declaratively, translate once on load, and never build a sentence by joining fragments.

Where substitution happens automatically and where it does notThe manifest and CSS are substituted by the browser at load; HTML documents must be translated by an external script at runtime._locales/<lang>/messages.jsonone bundle per localeconsumed two different waysmanifest.json + CSS__MSG_key__ substituted at loadHTML documentsapplyI18n() from an external module
The right-hand branch is the one you write — and the one that has to run before the first paint.

Step 1. Write messages a translator can move around

A placeholder is a named hole a translator can put anywhere in the sentence. Concatenation is a hole they cannot move at all.

 1// _locales/en/messages.json
 2{
 3  "syncedAt": {
 4    "message": "Synced $TIME$ from $DEVICE$",
 5    "description": "Status line in the popup. TIME is a relative time, DEVICE is a user-set name.",
 6    "placeholders": {
 7      "time":   { "content": "$1", "example": "2 minutes ago" },
 8      "device": { "content": "$2", "example": "Work laptop" }
 9    }
10  },
11  "quotaWarning": {
12    "message": "Using $USED$ of $TOTAL$",
13    "description": "Shown when sync storage is above 80% full.",
14    "placeholders": {
15      "used":  { "content": "$1", "example": "84 KB" },
16      "total": { "content": "$2", "example": "100 KB" }
17    }
18  }
19}

Execution context: Static JSON, read by the browser and cached for the life of the extension process. The description field never reaches a user — it is the only channel you have to a translator, and omitting it is why “Open” comes back translated as the adjective. Placeholder names are lowercase in the placeholders object and uppercase between dollar signs in the message; getting that wrong yields the literal $TIME$ in the output rather than an error. The format is identical in Chrome, Firefox and Safari.

1// Call it with positional substitutions in the order the placeholders declare.
2const line = chrome.i18n.getMessage("syncedAt", [relativeTime, deviceName]);

Execution context: Any extension surface, synchronously. getMessage returns an empty string for an unknown key rather than throwing, so a typo shows up as a blank label — wrap it in a helper that logs in development if you want the failure to be loud. Passing more substitutions than the message declares is silently ignored; passing fewer leaves the placeholder text in place.

Step 2. Understand the fallback chain before you add regions

The browser picks a bundle by narrowing the UI language, and it does not merge bundles — a partially translated pt_BR does not fall back key-by-key to pt.

How the browser chooses a locale bundleThe UI language is matched against the region-specific directory, then the base language, then default_locale, with no per-key merging.Is there a _locales directory for the exact UI language?pt_BR existsUse pt_BR onlymissing keys stay emptyKeep region bundles completeor do not ship themOnly pt existsUse ptregion subtag droppedBase-language bundles are saferone file per languageNeitherUse default_localethe manifest's declared defaultMust be 100% completeit is the last resort
No merging is the surprise — one missing key in pt_BR is a blank string, not the pt value.

Ship base-language bundles (de, pt, ar) unless a region genuinely diverges, and treat default_locale as the only bundle that must be exhaustively complete. A half-finished fr_CA is worse than no fr_CA at all, because it wins the match and then returns empty strings.

Step 3. Translate the DOM once, before first paint

 1// i18n.js
 2const missing = new Set();
 3
 4export function t(key, subs) {
 5  const value = chrome.i18n.getMessage(key, subs);
 6  if (!value && !missing.has(key)) { missing.add(key); console.warn("i18n: missing key", key); }
 7  return value || key;                       // show the key rather than nothing
 8}
 9
10export function applyI18n(root = document) {
11  for (const el of root.querySelectorAll("[data-i18n]")) el.textContent = t(el.dataset.i18n);
12
13  for (const el of root.querySelectorAll("[data-i18n-attr]")) {
14    for (const pair of el.dataset.i18nAttr.split(",")) {
15      const [attr, key] = pair.split(":").map((s) => s.trim());
16      el.setAttribute(attr, t(key));
17    }
18  }
19
20  document.documentElement.lang = chrome.i18n.getUILanguage();
21  document.documentElement.dir = chrome.i18n.getMessage("@@bidi_dir");
22}

Execution context: Popup, options page or side panel document; loaded with <script type="module" src="popup.js"> because inline script is blocked in every engine. Falling back to the key rather than an empty string turns an invisible failure into a visible one during testing. Setting lang on the root element is what tells a screen reader which pronunciation rules to use and is checked by every automated accessibility audit; @@bidi_dir is a predefined message available without declaring it.

1<!-- popup.html — the module runs before paint because the body is tiny -->
2<h1 data-i18n="popupHeading">Tab Curator</h1>
3<button id="sync" data-i18n="actionSync" data-i18n-attr="title:actionSyncHint">Sync now</button>
4<p id="status" role="status" aria-live="polite"></p>
5<script type="module" src="popup.js"></script>

Execution context: Popup markup. Leaving the English text in the HTML rather than an empty element is deliberate — if the script fails to load, the user sees English instead of a blank window, and it keeps the markup readable during development. The script tag sits at the end of the body so the nodes exist when the module evaluates.

Step 4. Handle counts without inventing a plural engine

chrome.i18n has no plural support. Do not build one — pick the branch in code and let each locale supply the strings its grammar needs.

Formatting concerns and where each is solvedCounts, numbers, dates and word order each belong to a different mechanism — the message bundle, Intl, or your own branching.ConcernSolved byAvailable everywhereWord order in a sentencePlaceholdersYesPlural categoryYour branch + 3 keysYesDigit grouping, numeralsIntl.NumberFormatYesRelative timesIntl.RelativeTimeFormatYesText direction@@bidi_dirYesStore listing textStore dashboardPer store
Only word order belongs in the bundle; the rest is Intl, which every engine implements.
1// counts.js
2export function groupCountLabel(n) {
3  if (n === 0) return t("groupsNone");
4  if (n === 1) return t("groupsOne");
5  return t("groupsMany", [new Intl.NumberFormat(chrome.i18n.getUILanguage()).format(n)]);
6}

Execution context: Any extension surface. Three keys cover English, German, Spanish and most of what an extension UI needs; languages with more plural categories will map several of them onto groupsMany, which is imperfect but honest. Intl.NumberFormat is available in the worker and in every extension document across all three engines and handles digit grouping and numeral systems that manual formatting gets wrong. For dates and relative times use Intl.RelativeTimeFormat for the same reason.

Step 5. Budget for text expansion in a fixed-width surface

A popup cannot grow past roughly 800 by 600 pixels, and German, Finnish and Russian routinely run 30 to 40 percent longer than English.

 1/* popup.css */
 2:root { --popup-width: 360px; }
 3body { inline-size: var(--popup-width); }
 4
 5button, .label {
 6  /* Let long labels wrap instead of overflowing a fixed-width control. */
 7  white-space: normal;
 8  overflow-wrap: anywhere;
 9  min-inline-size: 0;
10}
11
12.toolbar { display: flex; flex-wrap: wrap; gap: .5rem; }

Execution context: Stylesheet for the popup document. min-inline-size: 0 on a flex child is the fix for the specific failure where a long word refuses to shrink and pushes siblings out of the popup — flex items default to min-width: auto, which is content-based. Test with the longest locale you ship rather than English; the sizing rules and their interaction with the popup’s own constraints are covered in fixing popup size and overflow issues.

Cross-browser variation

  • Chrome/Edge: locale chosen from the browser UI language. chrome.i18n.getAcceptLanguages returns the user’s language list, useful for offering a manual override.
  • Firefox: identical bundle format and fallback behaviour. Firefox additionally offers browser.i18n.detectLanguage, which Chrome does not implement.
  • Safari: the UI language comes from the system rather than a browser setting, so a user cannot change it for Safari alone. Safari also applies system text scaling to extension pages more aggressively, making the expansion budget above more important, not less.
  • All engines: store listing text is localised through each store’s dashboard, not through _locales. Shipping a translated extension with an English listing is a common oversight — publishing to Firefox add-ons and Safari covers where those fields live.

Verification

  1. Launch the browser with a different UI language (--lang=de on Chrome, or change the system language for Safari) and confirm the extension name in the extensions list is translated — that proves the manifest substitution works.
  2. Open the popup and check the console for i18n: missing key warnings. There should be none.
  3. Temporarily rename _locales/de and confirm the extension falls back to default_locale rather than showing blanks.
  4. Add a deliberately long German string and confirm the popup wraps rather than clipping.
  5. Inspect the popup’s root element and confirm lang matches the active locale and dir is correct.

FAQ

Can I let the user choose a language inside the extension?

Not through chrome.i18n — it follows the browser. You can store a preference and do your own lookup against a bundle you load with fetch, at the cost of losing manifest substitution.

Why is my message showing as $TIME$ literally?

The placeholder name in the placeholders object does not match the token in the message, or the substitution array was not passed. Names are case-insensitive in the lookup but must otherwise match exactly.

Do I need default_locale if I only ship English?

Only if a _locales directory exists. With no _locales at all, the manifest strings are used as written.

How large can a message bundle get?

Large enough not to matter — bundles are read once at load and cached. Splitting them per surface is not supported and not needed.

Other UI/UX Patterns & Interactive Components Resources