Chapter 12·Scripts and layout·~10 min
Bidirectional text: RTL + LTR mixed strings
Half a billion people read right-to-left. The Unicode bidirectional algorithm handles the text; an interpolated price or brand name still comes out in the wrong place unless the app isolates it.
The problem
Arabic, Hebrew, Farsi, and Urdu read from right to left. Numbers and embedded English (brand names, prices, codes) keep their own direction, which the browser weaves into the surrounding paragraph. That works for static text. Interpolate a dynamic value ($999) into a translated string and the order can come out wrong. Most teams find it when a Saudi or Israeli user files a screenshot showing the price in the wrong place.
How it works
Why the dollar sign wanders
The Unicode bidirectional algorithm gives every character a directional class. Letters are strong: Arabic and Hebrew letters run right-to-left, Latin letters left-to-right. Digits are weak, with a direction of their own but little influence over their surroundings. Punctuation, spaces, and currency symbols carry no direction of their own: the algorithm classes them as weak or neutral and lets the surrounding text decide.
That last rule is the entire bug class. Interpolate "$999" into an Arabic sentence and the $ sits between an Arabic letter and a digit. Having no direction of its own, it resolves toward its strong neighbors and lands on the wrong side of the number. That is why the bug is content-dependent: a username that starts with a Latin letter and one that starts with a digit can lay out the same template differently.
The fix is to change the neighborhood, not the algorithm. Isolates (FSI…PDI characters, HTML's <bdi> or dir="auto", CSS unicode-bidi: isolate) tell the engine: lay this span out by its own rules, and treat the whole thing as one neutral block from the outside. Wrap every interpolated variable and no user-supplied value can restructure the sentence again.
The demo
Try these, in order
Each step reproduces one specific failure in the demo below.
- 1Keep the preset “Arabic with English brand name” and set the strategy to “Plain interpolation”. Compare the two output boxes: without isolation the price and date scramble into the Arabic sentence, and the $ drifts to the wrong side of 999.
- 2Switch the strategy to “FSI / PDI (recommended)”. Same template, fixed rendering. The raw-bytes panel shows [FSI]…[PDI] fencing each variable: invisible characters doing the layout work.
- 3Load the preset “Code snippet inside RTL paragraph” with plain interpolation. The rendering mangles getValue('user.name'), and the parentheses appear to flip: Unicode mirrors brackets in RTL context. Code inside RTL prose always needs isolation.
- 4Edit the {price} field: try “$999”, then “999 USD”. Un-isolated, the rendering changes depending on which neighbor is strong: the variable's display depends on content the template does not control.
Output · with FSI / PDI (recommended)
This box renders with dir="rtl", lang="ar". The browser runs Unicode TR9.
For comparison · without isolation
Watch the order of brand/price/date flip against the box above. Translator-delivered strings that lack U+2068 fail this way.
The raw byte sequence
اشتر [FSI]iPhone 14 Pro[PDI] الآن بسعر [FSI]$999[PDI] فقط، عرض حتى [FSI]12/31[PDI].
Cheat sheet
FSI/PDI: the modern "first strong character" isolation. Use this for any user-supplied or interpolated value. Some frameworks wrap values automatically (Android's BidiFormatter, Closure Templates); most web MessageFormat runtimes leave the marks to the caller.
LRE/RLE: legacy embedding marks, still found in older codebases. New code should use FSI/PDI.
LRM/RLM: single invisible direction hints. Place them around punctuation at the edge of an RTL sentence. Trailing periods and parentheses then land on the correct side.
The short version
Wrap every interpolated variable in FSI/PDI isolates. Characters with no direction of their own ($, digits, punctuation) take it from their neighbors. Never leave them to guess.
What to do about it
- Wrap every interpolated value in a
<bdi>element in HTML. The browser handles the rest. - For non-HTML contexts (push notifications, log lines, generated images), wrap the variable in FSI / PDI control characters:
`\u2068${value}\u2069`. - Set
dir="auto"on user-generated content fields. The browser picks the direction per item. - For mirroring layout (margins, icons, gutters), use logical CSS properties (
padding-inline-start,margin-inline-end) instead ofpadding-left.
Where this comes up
Who it concerns
Moments
- ·RTL launch readiness review
- ·When design hands off a flow that includes pricing or branded text
- ·Component library audit
See it in a market: Saudi Arabia: an RTL launch end to end →
Field note
The typical bug is a currency symbol on the wrong side: “$999” rendering as “999$” inside an Arabic sentence. It renders fine in English screenshots and breaks only in the locales the review never opened.