Ctrl + Shift + K comes out backwards in Arabic, and nothing throws
Adding dir="rtl" and switching to CSS logical properties is an
afternoon, and it is the part that works. What breaks afterwards is any left-to-right run
sitting inside a right-to-left sentence — a shortcut, a URL, a version number. It
raises nothing, logs nothing, and is invisible in the language you develop in.
10 September 2026
What actually breaks
The Unicode bidirectional algorithm classifies +, :,
., ,, /, - and whitespace as
neutral, and resolves each neutral run from the directions on either side of it.
Put Ctrl + Shift + K inside an Arabic sentence and the pluses and spaces
between the key names belong to the surrounding paragraph, not to the shortcut. The
segment is torn at its edges: the pieces are all there, in an order nobody typed.
This is the failure mode worth naming, because it defeats the usual defences. There is no exception to catch, no console warning to grep for, and no test that fails — your own screenshots look correct, because you are reading them left to right. The only people who see it are the ones who cannot report it in your issue tracker in your language.
Two layers, and you need both
The DOM layer: put dir="ltr" on the element. The HTML UA
stylesheet gives such an element both direction: ltr and
unicode-bidi: isolate, which is what makes it a directional island rather than
merely a left-to-right one. This covers anything that owns an element —
<kbd>, <code>.
The string layer: the DOM layer cannot help you when the value is substituted into an already-translated sentence, because the value has no element of its own — it arrives in the middle of a string from your message catalogue. Wrap it in isolate characters instead:
const FSI = '\u2068'; // FIRST STRONG ISOLATE — detects the direction of what it wraps
const PDI = '\u2069'; // POP DIRECTIONAL ISOLATE — closes FSI and LRI
const LRI = '\u2066'; // LEFT-TO-RIGHT ISOLATE — does not detect; pins
const isolate = (v) => (v ? FSI + v + PDI : ''); // direction unknown
const isolateLtr = (v) => (v ? LRI + v + PDI : ''); // direction known to be LTR
Returning the empty string for an empty value rather than a bare FSI + PDI
pair matters more than it looks: it keeps isolate(x) || '—' working. A
pair of invisible characters is truthy, so the fallback silently stops firing.
Write the control characters as escape sequences, always. Written literally they are invisible characters: invisible in the diff, invisible in review, and removed by any "strip invisible characters" tool as an obvious cleanup. The code keeps running afterwards. The isolation just quietly stops working, in the one language you do not read.
Pin the shortcut, do not detect it
Given both primitives, the shortcut looks like a job for isolate() —
auto-detection sounds strictly safer. It is the wrong one, and the reason is not
stylistic.
Modifier key names are localised: a German keyboard has Strg printed on
it, and showing that user Ctrl asks them to find a key that does not exist. So
modifier names go through the message catalogue like everything else. The moment a
translator renders one in Arabic script, the first strong character of
Ctrl + Shift + K is an Arabic letter, FSI resolves the whole run
as right-to-left, and the key order flips. Your correctness now depends on
a decision made in a translation file.
LRI removes that dependency. And pinning is right on the merits:
physical keyboards do not mirror. An Arabic speaker's Ctrl is still at the
bottom left and the number row still runs 1234567890 left to right. Rendering the
combination right to left inverts a physical fact and makes the reader mentally reverse it
before their fingers can follow. Microsoft and Apple keep key combinations left-to-right in
their Arabic interfaces too. This is the correct answer, not the lazy one.
The corollary for translators: do not translate the modifier names.
Arabic, Hebrew and Persian keyboards have Ctrl / Alt /
Shift printed on the keycaps in Latin letters. Same test as the German case,
opposite answer: show what is on the key.
You cannot tell a translation from a fallback at runtime
For a whole string whose language you do not know, the instinct is to check whether the
key was translated and pick the direction from that. It cannot be done:
chrome.i18n falls back per key to your default_locale,
so a missing key returns the English sentence, not an empty string. We measured this on
31 August 2026 — under an Arabic UI, getMessage() for an untranslated key
returned the English original. Both cases are non-empty strings. There is nothing to
branch on.
So do not branch. Set dir="auto" on the element and let the browser read
its own content: an Arabic translation resolves right-to-left, an English fallback resolves
left-to-right, and when the translation lands later no code changes.
Its known limit is that dir="auto" looks at the first strong
directional character only, so an Arabic sentence opening with a Latin brand name resolves
as left-to-right. The fix is not to abandon it — it is to turn the limit into an
invariant something checks: reject any message in an RTL catalogue that begins with a
strong LTR character, and either rewrite the sentence or prefix it with U+200F RLM. We run
that as a build rule; a release that fails it does not get packaged.
One more thing not to hand-roll: do not keep your own list of RTL
languages. Any list you write will miss some of ur, ps,
sd, ckb, yi, dv, and it will drift from
whatever locale the browser actually selected. chrome.i18n already answers
this: getMessage('@@bidi_dir') returns ltr or rtl
for the locale your strings came from, which is exactly the one you want to agree with.
Two that only show up when you look at it
- Direction words have to be mirrored in translation, and that feels wrong
until you look. Chrome's tab strip mirrors under an RTL locale — we
confirmed it on 31 August 2026 by capturing the actual window: tabs opened A, B, C sit
right to left, with "+" at the far left. Our implementation is index-based, so "move tab
left" is
index - 1, which under Arabic moves the tab visually right. The Arabic, Hebrew and Persian strings for those commands are therefore deliberately swapped, with a note in the catalogue telling the next translator not to "fix" them back. A label describes what the user sees, not what the English key is called. chrome.i18nhas no plural support, and Arabic has six categories. Select the CLDR category withIntl.PluralRules, look up<key>_<category>and fall back to_other: Arabic needszero/one/two/few/many/other, Hebrew four. When a form reads better as a word than a number, delete the now-unusedplaceholdersblock — the store validator objects to placeholders that no message body uses.
Static rules do not replace looking at it
We do gate releases on static rules — physical direction attributes, a single
creation point for directional content, every HTML entry point actually applying the
direction, the bidi module's required exports, the first-strong-character rule on RTL
catalogues. Every rule is mutation-tested: for each one we break the thing it guards and
confirm that rule, and only that rule, goes red. One of those mutations caught the guard
matching a neighbour — the "is applyDir() called" rule was satisfied by
the export function applyDir( line in the module that defines it, so deleting
both real calls still printed green. A guard has to check the thing it guards, not what
sits next to it.
And it is still not enough. Every real problem in our first RTL pass was found by
rendering the interface in Arabic and looking at it: a sentence's full stop jumping to the
wrong end on an English fallback, the tab-strip mirroring above, and a
<kbd> element line-breaking in the middle of a key combination. Static
rules cannot see the arrangement.
Why this is worth an afternoon
Hundreds of millions of people read Arabic, Hebrew or Persian in a browser, and the
extension ecosystem largely does not serve them — not because the market is small,
but because getting it right is fiddly and getting it wrong is invisible from inside a
left-to-right locale. The work is bounded, and it is done once: our bidi module is one
file that imports nothing, copied unchanged into each extension. Both of ours ship 24
locales including ar, he and fa.
Neither of us is claiming a number for what it earned. Store dashboards do not break installs down in a way that would settle it, so anyone quoting you a lift from RTL support is guessing.
We build small Chrome extensions and write these up as we go. Custom Keyboard Shortcuts rebinds browser actions to keys you choose, per site or everywhere, and is free on the Chrome Web Store. Highlight Reader reads a page aloud and highlights each sentence as it speaks; it is also free, and a paid add-on for it — word-level highlighting and higher-quality voices — is described here. The add-on belongs to the reader, not to the shortcut extension.
The API and the code are the easy half. The half that costs days is the store: the slot cap on a new account, the listing text going read-only mid-review, the uninstall URL you cannot change after you ship. The ten that caught us — three of them in full, no signup.
More from the same build
Every one of these is something we hit while shipping the two extensions on this site, written up from our own code and measurements.
- Chrome Web Store API v1 to v2 — what actually changes
- Chrome Extension Ship Kit — everything between “it works” and “it is live”
- A new Chrome Web Store account can publish two extensions
- setUninstallURL ships inside the extension, so the page has to exist first
- chrome.runtime.id is a different string on Edge, and it breaks your store links silently
- suggested_key is a suggestion: commands.getAll() can hand you an empty shortcut