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.

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.