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.
Table of Contents
- Root cause:
dirmirrors inline flow, not your coordinates - Step 1. Set the direction from the locale, not from a guess
- Step 2. Replace the physical properties
- Step 3. Leave numbers, code and URLs alone
- Step 4. Re-check the width budget
- Step 5. Handle injected UI, which inherits the page’s direction
- Cross-browser variation
- Verification
- FAQ
- Related
You set dir="rtl", reload the popup, and the text flips while everything else stays exactly where it was: the close button is still top-right where it should now be top-left, the chevron still points the wrong way, and a badge that was tucked against the right edge now sits over the heading. Bidirectional support is not a text feature — it is a layout audit, and roughly two-thirds of it is replacing physical CSS properties you wrote without thinking. This page is part of Internationalization & Accessibility and covers the audit for extension surfaces specifically, where the fixed popup width makes the failures sharper.
Root cause: dir mirrors inline flow, not your coordinates
Setting the direction changes the inline axis: text runs right to left, and anything laid out with logical properties or normal flex/grid ordering follows. Anything positioned with left, right, margin-left, padding-right, text-align: left, a translateX, or a rotation stays exactly where you put it, because those are physical coordinates and physical coordinates have no direction.
Step 1. Set the direction from the locale, not from a guess
@@bidi_dir is a predefined message the browser answers from the active locale. There is nothing to add to your bundle.
1// i18n.js — part of the same pass that translates the DOM
2export function applyDirection() {
3 const root = document.documentElement;
4 root.lang = chrome.i18n.getUILanguage(); // e.g. "ar", "he", "fa"
5 root.dir = chrome.i18n.getMessage("@@bidi_dir"); // "rtl" or "ltr"
6}
Execution context: Popup, options page or side panel document, from an external module. @@bidi_dir needs no declaration in messages.json and is available in Chrome, Firefox and Safari. The companion messages @@bidi_start_edge and @@bidi_end_edge return left/right strings for the rare case where you must compute a physical value in JavaScript — prefer a CSS logical property whenever you can, because it also handles the case where a single component is overridden to the opposite direction.
Step 2. Replace the physical properties
This is mechanical and worth doing in one pass rather than reactively.
1/* popup.css — before and after, side by side for the audit */
2.row {
3 display: flex;
4 align-items: center;
5 gap: .5rem;
6 padding-inline: .75rem; /* was: padding-left / padding-right */
7 border-inline-start: 3px solid var(--accent); /* was: border-left */
8 text-align: start; /* was: text-align: left */
9}
10
11.badge {
12 position: absolute;
13 inset-inline-end: .5rem; /* was: right: .5rem */
14 inset-block-start: .5rem; /* was: top — block axis, unchanged, but keep it consistent */
15}
16
17/* Rotation is physical: mirror it explicitly. */
18.chevron { transition: rotate .15s; }
19[dir="rtl"] .chevron { scale: -1 1; }
Execution context: Stylesheet loaded by the extension document. Logical properties are supported by every engine that supports Manifest V3, so no fallback is needed. scale: -1 1 mirrors the glyph rather than rotating it, which is what you want for a chevron or an arrow; a spinner or a clock face must not be mirrored, because those directions are absolute rather than reading-order relative. Icons that contain text — a “back” arrow with a label baked in — must be replaced rather than flipped.
Step 3. Leave numbers, code and URLs alone
Latin-script content inside right-to-left text is where bidirectional layout genuinely gets hard, and the browser’s algorithm does most of it correctly if you do not fight it.
1<!-- An API name inside an Arabic sentence: isolate it so the surrounding text does not reorder it. -->
2<p data-i18n="quotaExplainer"></p>
3<p>
4 <bdi><code>chrome.storage.sync</code></bdi>
5 <span data-i18n="quotaSuffix"></span>
6</p>
7
8<!-- A file path or URL the user may copy: force it left-to-right regardless of context. -->
9<code dir="ltr" class="path">~/Library/Application Support/…</code>
Execution context: Extension document markup. <bdi> isolates its contents from the surrounding bidirectional algorithm, which stops a trailing punctuation mark from jumping to the wrong end of a code span — the classic symptom is a period appearing at the start of an API name. Setting dir="ltr" on a path is stronger and appropriate when the content is never natural language. Both elements behave identically in all three engines.
Step 4. Re-check the width budget
A popup cannot grow past roughly 800 by 600 pixels, so the extra width has to come out of the layout rather than the window.
1/* popup.css */
2body { inline-size: 360px; }
3
4.toolbar { display: flex; flex-wrap: wrap; gap: .5rem; }
5
6.toolbar > * {
7 min-inline-size: 0; /* flex items default to content-based minimum */
8 overflow-wrap: anywhere; /* long German compounds have no break points */
9}
10
11.title { text-wrap: balance; }
Execution context: Popup stylesheet. min-inline-size: 0 is the specific fix for a flex child that refuses to shrink below its longest word and pushes its siblings out of the popup — the default min-width: auto on flex items is content-based, which is exactly wrong here. The hard ceiling on popup dimensions and how to work within it is covered in fixing popup size and overflow issues.
Step 5. Handle injected UI, which inherits the page’s direction
A control your content script adds to a page is inside that page’s document, so it picks up the page’s dir — which may be right-to-left when your extension’s locale is not, or the reverse.
1// content.js — pin the widget to the extension's direction, not the page's
2const host = document.createElement("div");
3host.dir = chrome.i18n.getMessage("@@bidi_dir");
4host.lang = chrome.i18n.getUILanguage();
5
6const shadow = host.attachShadow({ mode: "open" });
7shadow.append(styleSheetFor(host.dir), buildWidget());
8document.body.append(host);
Execution context: Content script, isolated world, operating on the page’s DOM. Setting dir and lang on the host element establishes the correct context for everything inside, including the shadow tree. A shadow root also stops the page’s stylesheet from reaching your controls, which is the other half of the problem — a page with * { text-align: right } will otherwise reformat your widget. The isolation trade-offs are covered in best practices for content script isolation in MV3.
Cross-browser variation
- Chrome/Edge:
@@bidi_dirfollows the browser UI language. Logical properties and<bdi>are fully supported. Launch with--lang=arto test without changing your system. - Firefox: identical behaviour, and the add-ons manager itself mirrors when the browser is in a right-to-left locale — an embedded options page must look correct inside a mirrored container.
- Safari: the locale comes from the system, so testing means changing the macOS or iOS language. Safari also applies system text scaling to extension pages, which compounds with translation expansion; test Arabic at 150% text size.
- All engines: the browser’s own popup positioning mirrors automatically — the popup anchors to the toolbar button, which moves with the direction. Do not try to compensate for it.
Verification
- Launch Chrome with
--lang=arand open the popup. Text should be right-aligned, the accent border on the correct edge, and the close button mirrored. - Check every chevron, arrow and progress indicator. Reading-order glyphs should be mirrored; clocks and spinners should not.
- Find a code span or URL in the UI and confirm the punctuation stays attached to the right token.
- Switch to German and confirm nothing overflows the popup at 360 pixels.
- Inject the content-script widget into a right-to-left page while the extension locale is English, and confirm the widget stays left-to-right.
FAQ
Do I need a separate RTL stylesheet?
No. Logical properties plus a handful of [dir="rtl"] rules for mirrored glyphs cover it. A duplicated stylesheet drifts within one release.
Will dir="rtl" reverse my flex row?
Yes — flex-direction: row follows the inline axis, so the visual order reverses, which is usually what you want. Use row rather than row-reverse, and let the direction do the work.
What about the extension’s name and store listing?
Those are separate: the name comes from _locales via manifest substitution, and the store listing is entered in each store’s dashboard.
How do I test without changing my system language?
Chrome and Edge accept --lang=; Firefox has a language setting in preferences. Safari follows the system, with no per-app override.
Related
- Localising extension UI with the i18n API — the bundle that decides which direction applies.
- Making popups and options keyboard navigable — tab order in a mirrored layout follows DOM order, not visual order.
- Fixing popup size and overflow issues — the width ceiling translated text runs into.
- Internationalization & Accessibility — the guide this page belongs to.