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.
Prerequisites checklist
- A
default_localein the manifest. Without it the browser refuses to load an extension that has a_localesdirectory 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.
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.
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 inmanifest.jsonand in CSS, and nowhere else. chrome.i18n.getMessagereturns 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.
Related
- Localising extension UI with the i18n API — bundles, placeholders and the fallback chain.
- Making popups and options keyboard navigable — focus order in surfaces with no browser chrome.
- Supporting RTL locales in extension pages — logical properties, mirrored icons and width.
- Popup interface design — the surface most of this applies to first.
- Options page layouts — the larger surface with the most strings.
- Up to the UI/UX Patterns & Interactive Components overview.