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.
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.
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.