Internationalization & Accessibility

Localise and make accessible every MV3 extension surface: the i18n API, _locales bundles, CSP-safe DOM translation, keyboard navigation, RTL layouts and live announcements.

An extension popup is a 360-pixel window with no address bar, no browser chrome to orient the user, and — because the extension content security policy forbids inline handlers — no way to sprinkle behaviour into the markup. That combination makes both localisation and keyboard access harder than on the open web, and it makes getting them wrong less visible: nobody files a bug saying “your popup traps my focus”, they uninstall. This guide is part of UI/UX Patterns & Interactive Components and covers the two concerns together, because in an extension they share the same infrastructure — a translation pass over static markup, run once, from an external script.

One translation and labelling pass across every surfaceA shared module reads the locale bundle and applies text, direction and accessible names to the popup, options page, side panel and injected UI._locales/<lang>/messages.jsonselected by the browser UI languagechrome.i18n.getMessagesynchronous lookupone applyI18n(root) call per surfacePopupdata-i18n text + aria-labelOptionslabels, legends, errorsSide panelheadings, live regionsInjected UIbuttons in the page
Every surface runs the same pass — the alternative is four subtly different translations of the same string.

Prerequisites checklist

  • A default_locale in the manifest. Without it the browser refuses to load an extension that has a _locales directory at all, with an error that does not name the missing key.
  • Every user-visible string in a bundle. A string hard-coded in HTML is one that can never be translated; the audit for this is a grep, not a judgement call.
  • A DOM translation pass that runs from an external script — no __MSG_ substitution happens in extension HTML, only in the manifest and CSS.
  • Real focus order in every surface. Popups open with focus on the document body; if the first interactive element is not reachable with one Tab, keyboard users cannot start.
  • An accessible name on every control. Icon-only buttons are the norm in a popup, and an icon has no name.

1. Declare the locales and use messages in the manifest

The manifest is one of only two places where __MSG_name__ substitution happens automatically.

 1{
 2  "manifest_version": 3,
 3  "name": "__MSG_extensionName__",          // substituted by the browser at load
 4  "description": "__MSG_extensionDescription__",
 5  "default_locale": "en",                    // REQUIRED once _locales exists
 6  "action": { "default_title": "__MSG_actionTitle__" },
 7  "commands": {
 8    "toggle-panel": {
 9      "suggested_key": { "default": "Ctrl+Shift+P" },
10      "description": "__MSG_commandTogglePanel__"   // shown in the browser's shortcut UI
11    }
12  }
13}

Execution context: Parsed by the extension host at load time; substitution happens before any of your code runs. The message names are case-sensitive and a missing one makes the extension fail to load rather than falling back. Chrome, Firefox and Safari all implement manifest substitution identically; the store listings themselves are localised separately, through each store’s own dashboard, which is a step people discover only after publishing an untranslated listing.

1// _locales/en/messages.json
2{
3  "extensionName":        { "message": "Tab Curator" },
4  "extensionDescription": { "message": "Group and restore your tabs by project." },
5  "actionTitle":          { "message": "Open Tab Curator" },
6  "commandTogglePanel":   { "message": "Toggle the side panel" },
7  "groupCount":           { "message": "$COUNT$ groups", "placeholders": {
8                              "count": { "content": "$1", "example": "3" } } }
9}

Execution context: Static JSON read by the browser, never by your code directly. Placeholders are how you keep word order translatable — a translator can move $COUNT$ anywhere in the sentence, which string concatenation would not allow. Firefox and Safari read the same format, so one _locales tree serves all three engines.

2. Translate the DOM from an external script

Extension HTML gets no automatic substitution. Mark the nodes declaratively and walk them once.

 1// i18n.js — shared by every surface
 2export function applyI18n(root = document) {
 3  for (const el of root.querySelectorAll("[data-i18n]")) {
 4    const text = chrome.i18n.getMessage(el.dataset.i18n);
 5    if (text) el.textContent = text;
 6  }
 7  for (const el of root.querySelectorAll("[data-i18n-attr]")) {
 8    // Format: "aria-label:popupClose,title:popupClose"
 9    for (const pair of el.dataset.i18nAttr.split(",")) {
10      const [attr, key] = pair.split(":");
11      const text = chrome.i18n.getMessage(key.trim());
12      if (text) el.setAttribute(attr.trim(), text);
13    }
14  }
15  document.documentElement.lang = chrome.i18n.getUILanguage();
16  document.documentElement.dir = chrome.i18n.getMessage("@@bidi_dir");
17}

Execution context: Popup, options page, side panel or any extension document, loaded as an external module because the content security policy blocks inline scripts. chrome.i18n.getMessage is synchronous and needs no permission, so the pass adds no measurable latency to popup open. Setting lang matters for screen-reader pronunciation and is checked by every accessibility audit; @@bidi_dir is a predefined message that returns ltr or rtl based on the active locale, and it exists in all three engines.

3. Give every control a name and a reachable focus

An icon button with no text is invisible to a screen reader and, if it is only reachable by pointer, invisible to a keyboard user too.

1<!-- popup.html -->
2<button id="close" type="button"
3        data-i18n-attr="aria-label:popupClose,title:popupClose">
4  <svg aria-hidden="true" focusable="false" viewBox="0 0 16 16"><path d="M4 4l8 8M12 4l-8 8"/></svg>
5</button>
6
7<ul role="list" id="groups" aria-labelledby="groups-heading"></ul>
8<p id="empty" data-i18n="groupsEmpty" hidden></p>
9<p id="status" role="status" aria-live="polite" class="visually-hidden"></p>

Execution context: Popup markup. aria-hidden="true" plus focusable="false" on the SVG stops assistive technology announcing the path and stops older engines putting the SVG itself in the tab order. The role="status" paragraph is the live region every dynamic update will be announced through — creating it up front rather than on demand is what makes announcements reliable, and the reasons are worked through in announcing dynamic updates to screen readers.

1// popup.js
2import { applyI18n } from "./i18n.js";
3
4document.addEventListener("DOMContentLoaded", () => {
5  applyI18n();
6  // The popup opens with focus on <body>; move it to the first meaningful control.
7  document.querySelector("[autofocus], button, [href], input, select, textarea")?.focus();
8});

Execution context: Popup document, which is created fresh on every open and destroyed on close — there is no state to preserve between runs, so the pass runs each time. Chrome and Firefox both open the popup with focus on the body; Safari sometimes leaves focus in the page behind it, so an explicit focus() call is required rather than optional. Do not steal focus in an injected page UI, where the user’s focus belongs to the page.

4. Handle right-to-left as layout, not as a flip

dir="rtl" mirrors inline text automatically. Everything you positioned with physical properties stays where it was.

What mirrors on its own, and what you have to mirrorInline text, logical CSS properties, physical properties, rotations and directional icons compared on whether setting dir is enough.Layout concernMirrors with dirNeeds a ruleInline text flowYesNoflex-direction: row orderYesNopadding-inline / border-inlineYesNomargin-left / rightNoSwap for logicaltransform / rotateNo[dir=rtl] overrideChevrons and arrowsNoscale: -1 1
The bottom two rows are the entire manual part of right-to-left support.
 1/* popup.css — logical properties mirror themselves; physical ones do not */
 2.row {
 3  display: flex;
 4  gap: .5rem;
 5  padding-inline: .75rem;       /* not padding-left / padding-right */
 6  border-inline-start: 3px solid var(--accent);
 7}
 8
 9.icon-chevron { rotate: 0deg; }
10[dir="rtl"] .icon-chevron { rotate: 180deg; }   /* directional glyphs need explicit handling */

Execution context: Stylesheet loaded by the extension document. Logical properties are supported in every engine that supports Manifest V3, so there is no fallback to write. The exception is directional iconography — a chevron, a back arrow, a progress indicator — which carries meaning that mirroring does not fix automatically. Supporting RTL locales in extension pages covers the full audit including popup width, which changes noticeably in German and Arabic.

5. Keep the keyboard path complete

A popup is a small surface, which makes a broken tab order both more likely and more obvious.

The keyboard path through a popupOpening moves focus to the first control, arrow keys move within the list, Escape closes, and focus never leaves the popup while it is open.UserPopup documentGroup listLive regionpopup opensfocus() the first controlTab once into the listArrow keys move the roving tabindexselection change announcedrole=status, politeEscape closes the popup
The list uses a roving tabindex so one Tab enters it and arrows move inside it.
 1// popup-list.js — roving tabindex: one stop for the whole list
 2const list = document.getElementById("groups");
 3
 4list.addEventListener("keydown", (event) => {
 5  const items = [...list.querySelectorAll('[role="option"]')];
 6  const index = items.indexOf(document.activeElement);
 7  if (index === -1) return;
 8
 9  const next = { ArrowDown: index + 1, ArrowUp: index - 1, Home: 0, End: items.length - 1 }[event.key];
10  if (next === undefined) return;
11
12  event.preventDefault();
13  const target = items[Math.max(0, Math.min(items.length - 1, next))];
14  items.forEach((el) => { el.tabIndex = el === target ? 0 : -1; });
15  target.focus();
16});

Execution context: Popup document. A roving tabindex keeps the whole list to a single tab stop, so a user with twenty groups does not press Tab twenty times to reach the close button. preventDefault stops the arrow key also scrolling the popup, which in Chrome produces a jarring double movement. All three engines dispatch the same keydown events here; the difference is that Safari’s popup can be dismissed by the system while focus is inside it, so never rely on a cleanup that only runs on an explicit close.

6. Building both in from the start

Internationalisation and accessibility share an uncomfortable property: both are cheap when designed in and expensive when retrofitted. An extension written with hard-coded English strings and click-only controls can be fixed later, but every string has to be found, every custom control rebuilt, and every layout re-checked for longer text and right-to-left flow. The cost grows with every screen shipped in the meantime.

A small set of habits, applied from the first commit, removes most of that cost. Put every user-visible string through chrome.i18n.getMessage even while English is the only locale, so adding a second language is a translation task rather than an engineering one. Use native form controls and buttons, and style them, rather than building interactive elements from divs — native elements bring keyboard support, focus handling and screen-reader semantics that custom widgets have to reimplement. Lay out with logical CSS properties (margin-inline-start, padding-block) instead of left and right, so right-to-left locales work without a separate stylesheet. And give every icon-only button an accessible name from the start.

1// One helper, used from the first screen onwards
2export function t(key, ...subs) {
3  const s = chrome.i18n.getMessage(key, subs.map(String));
4  if (!s && import.meta.env.DEV) console.warn("[i18n] missing key", key);
5  return s || key;
6}

Execution context: any extension page. Logging missing keys in development makes an untranslated string visible the moment it is added, rather than when a translator finds it months later.

The two concerns also reinforce each other in testing. A screen-reader pass — described in testing extension UI with a screen reader — surfaces unlabelled controls and untranslated accessible names in the same session, and a pseudo-locale build that lengthens every string by a third exposes layouts that break under German or Finnish before a single real translation exists.

Store listings deserve the same early attention: the name, description and command descriptions are translated through the manifest and the dashboard, not through the UI, and they decide whether a non-English user installs at all — see translating manifest fields and store listings.

The reward for this discipline is disproportionate. Extensions reach a global audience from the first day in every store, and a large share of installs typically come from outside the developer’s own language. Accessible, translatable UI is therefore not a niche improvement but a direct route to more users who can actually use what they installed — and the habits above cost almost nothing when they are part of how every screen is built rather than a project scheduled for later.

When priorities have to be set, start where users meet the extension first: the store listing and manifest strings, the popup’s primary action, and the keyboard path to open it. Those three cover the moment of installation and the most frequent interaction, and fixing them helps every user before any deeper screen is touched. The options page, onboarding and less common flows follow, in order of how often they are used.

Record what was checked and when, even informally — a short note in the release checklist listing the locales reviewed and the surfaces tested with a screen reader. That record is what lets the next release build on this one instead of starting the audit from nothing, and it makes regressions visible as a change from a known-good state.

MV3 constraints to design around

  • No inline handlers or inline <script>. Every listener is attached from an external file, which is also why the translation pass is a script rather than a template.
  • No __MSG_ substitution in extension HTML. It works in manifest.json and in CSS, and nowhere else.
  • chrome.i18n.getMessage returns an empty string for an unknown key, not an error — a typo produces a blank label that is easy to miss until a screen reader reads nothing.
  • The UI language is the browser’s, not the page’s. There is no API to override it per user; a language picker means storing a preference and doing your own lookup.
  • Popups have no title bar. The document <title> is not shown, so it cannot be the accessible name of the surface — use a visible or visually hidden heading.
  • A popup can be dismissed at any time. Never rely on an unload handler to save state; write on change, as covered in preserving popup state when it closes.
  • Injected UI inherits the page’s stylesheet. Text direction, font size and focus outlines can all be overridden by the host page, so injected controls need their own reset.

Cross-browser notes

Chrome/Edge (baseline): full chrome.i18n support including getUILanguage, getAcceptLanguages and the @@bidi_* predefined messages. The locale is chosen from the browser UI language with a fallback chain down to default_locale. The extensions page shows the localised name immediately after install.

Firefox: browser.i18n is complete and behaves identically, and Firefox additionally exposes i18n.detectLanguage for guessing the language of a text sample. Firefox falls back to default_locale on any unmatched region subtag, the same as Chrome. Its add-on manager renders the localised name and description from the same bundle.

Safari: browser.i18n is supported, with the UI language coming from the system rather than a browser-level setting, so a user with an English system and a Spanish browsing habit sees English. Safari’s popup rendering also applies system font scaling more aggressively, so a layout that only works at the default font size will break for users with larger text — test at 200% before shipping.

Where to go next

Each page under this guide takes one surface deeper. Localising extension UI with the i18n API covers placeholders, plurals and the fallback chain. Making popups and options keyboard navigable works through focus order, roving tabindex and Escape handling. Supporting RTL locales in extension pages is the layout audit. Announcing dynamic updates to screen readers covers live regions in a surface that is destroyed and recreated constantly.

Further guides in this topic

The guides below go deeper into specific internationalization and accessibility problems that the sections above only touch on — each one starts from a concrete symptom and ends with a way to verify the fix.

  • Testing Extension UI with a Screen Reader — Test a popup, options page and injected UI with NVDA, VoiceOver and axe — reaching the popup by keyboard, what to listen for, and automating the checks that do not need a human.
  • Translating Manifest Fields and Store Listings — Localise an extension’s name, description, action title and command descriptions through _locales and _MSG placeholders, and keep store listings in step with the shipped translations.
  • Formatting Dates and Numbers with Intl — Format dates, times, relative times, numbers, currencies, units and lists correctly in an extension’s UI with the Intl APIs: matching the browser UI language, caching formatters, time zones, and testing locales.
  • Managing Focus in Popups and Dialogs — Manage keyboard focus in extension popups, side panels and in-page dialogs: initial focus when the popup opens, the native dialog element, focus trapping, restoring focus after close, and focus inside shadow DOM.
  • Plurals and Placeholders in messages.json — Handle plurals and placeholders correctly in an extension’s messages.json: named placeholders with $1 substitutions, why chrome.i18n has no plural rules, using Intl.PluralRules with suffixed keys, and keeping translators informed.
  • Respecting Reduced Motion and Forced Colors — Make extension popups, panels and injected UI respect prefers-reduced-motion, forced-colors (Windows High Contrast), prefers-contrast and color-scheme: CSS media queries, system colours, focus visibility and testing in DevTools.

Formatting Dates and Numbers with Intl

Format dates, times, relative times, numbers, currencies, units and lists correctly in an extension's UI with the Intl APIs: matching the browser UI language, caching formatters, time zones, and testing locales.

Managing Focus in Popups and Dialogs

Manage keyboard focus in extension popups, side panels and in-page dialogs: initial focus when the popup opens, the native dialog element, focus trapping, restoring focus after close, and focus inside shadow DOM.

Plurals and Placeholders in messages.json

Handle plurals and placeholders correctly in an extension's messages.json: named placeholders with $1 substitutions, why chrome.i18n has no plural rules, using Intl.PluralRules with suffixed keys, and keeping translators informed.

Respecting Reduced Motion and Forced Colors

Make extension popups, panels and injected UI respect prefers-reduced-motion, forced-colors (Windows High Contrast), prefers-contrast and color-scheme: CSS media queries, system colours, focus visibility and testing in DevTools.

Testing Extension UI with a Screen Reader

Test a popup, options page and injected UI with NVDA, VoiceOver and axe — reaching the popup by keyboard, what to listen for, and automating the checks that do not need a human.

Translating Manifest Fields and Store Listings

Localise an extension's name, description, action title and command descriptions through _locales and __MSG_ placeholders, and keep store listings in step with the shipped translations.

Announcing Dynamic Updates to Screen Readers

Live regions in MV3 surfaces that are destroyed and recreated: polite versus assertive, why an injected region never announces, and how to report background worker events.

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.

Making Popups and Options Keyboard Navigable

Fix focus order in MV3 surfaces with no browser chrome: initial focus on popup open, roving tabindex for lists, Escape handling, and focus that survives a re-render.

Supporting RTL Locales in Extension Pages

Make an MV3 popup, options page and injected UI work in Arabic and Hebrew: @@bidi_dir, logical CSS properties, mirrored icons, and text that no longer fits.