Skip to content
Locale Lab

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.

UnicodebidiFSI/PDI

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.

  1. 1
    Keep 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.
  2. 2
    Switch 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.
  3. 3
    Load 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.
  4. 4
    Edit 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)

اشتر ⁨iPhone 14 Pro⁩ الآن بسعر ⁨$999⁩ فقط، عرض حتى ⁨12/31⁩.

This box renders with dir="rtl", lang="ar". The browser runs Unicode TR9.

For comparison · without isolation

اشتر iPhone 14 Pro الآن بسعر $999 فقط، عرض حتى 12/31.

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 of padding-left.

Where this comes up

Who it concerns

EngineeringDesignQA

Moments

  • ·RTL launch readiness review
  • ·When design hands off a flow that includes pricing or branded text
  • ·Component library audit

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.

W3C: Inline markup and bidirectional text ↗

Terms in this chapter

Where to read more

Related chapters