When I added Arabic to my app, I did what the tutorials tell you to do: flip dir, add a couple of rules for inputs, mirror the tab bar, done. Ten lines of CSS in a 258-line stylesheet. I shipped it, looked at a screenshot, saw the text sitting on the right side of the screen, and called RTL "supported".
Months later I sat down to audit it properly. The audit started with the three rules in that block. Two of them do not do what I thought they did.
This post is what I measured, how I measured it, and the checklist I ended up with. It applies to any app that ships an RTL locale, not just mine.
The ten lines
Here is the entire RTL section of my stylesheet, comments included:
/* direction:rtl makes text right-align and reverses flex rows automatically. Do not add text-align:right to h1/p/button — it breaks centered elements. */ [dir="rtl"] { direction: rtl; } [dir="rtl"] input, [dir="rtl"] textarea { text-align: right; } [dir="rtl"] .tab-bar { flex-direction: row-reverse; } Three rules, three declarations: direction, text-align, flex-direction. The switch itself is one line in React:
useEffect(() => { document.documentElement.lang = language; document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr'; }, [language]); If you have written RTL support before, you already noticed the contradiction: my own comment says direction:rtl reverses flex rows automatically, and then the third rule reverses them again.
How I measured it
I did not want to reason from the spec and hope. I built a throwaway probe page, opened it in the browser I actually ship in (Chrome 156), and read the layout back with offsetLeft:
function build(dirCss, flex) { const w = document.createElement('div'); w.style.cssText = `width:320px;display:flex;flex-direction:${flex};direction:${dirCss}`; for (const n of ['A', 'B', 'C']) { const s = document.createElement('span'); s.textContent = n; w.appendChild(s); } document.body.appendChild(w); return w; } const order = w => [...w.children].sort((a, b) => a.offsetLeft - b.offsetLeft).map(e => e.textContent).join(''); Four configurations, four answers, in the order children were appended as A B C:
| configuration | visual order, left to right |
|---|---|
direction: ltr + row | ABC |
direction: rtl + row | CBA |
direction: rtl + row-reverse | ABC |
direction: ltr + row-reverse | CBA |
Row 2 is the behaviour my comment describes correctly. Row 3 is what my stylesheet actually applies to the tab bar. row follows the inline base direction, so under RTL row already lays children right-to-left; row-reverse means "opposite of the inline direction", which under RTL is left-to-right. Stacking them gives you back the LTR order.
So the third rule does not mirror my tab bar. It un-mirrors it. My four tabs — Create, My Names, Daily Fortune, Profile — sit in exactly the same left-to-right order in Arabic as they do in English. If the intent was "Arabic users get a mirrored bar", the rule delivered the opposite; if the intent was "the bar should look like the English one", then the rule is redundant, because the browser was going to draw it mirrored and I had to actively cancel that.
Either way, the fix is the same one-liner: delete the rule.
The second rule was a no-op
text-align: right on inputs. I added it because the first screenshot showed typed text sitting against the wrong edge... except it could not have, because direction on an ancestor already does this.
I checked instead of arguing with myself:
const d = document.createElement('div'); d.dir = 'rtl'; d.textContent = 'نص'; getComputedStyle(d).textAlign; // "start" const inp = document.createElement('input'); inp.dir = 'rtl'; getComputedStyle(inp).textAlign; // "start" Computed value is start, in both cases. start is a direction-relative keyword: it resolves to the right under RTL and to the left under LTR. The initial value of text-align is already start, so under direction: rtl a text block or an input is already right-aligned before I touch it. My rule sets a value that is, by definition, where the text already was.
Delete it. Three rules become one.
I want to be precise about why this matters, because "harmless dead CSS" is not the interesting part. The interesting part is that I wrote this block by pattern-matching tutorials, and the two rules that felt most load-bearing were the two that did nothing. The single line that actually carries RTL — direction: rtl — is the one I would have written from first principles in thirty seconds. The rest was ceremony that made the diff look like work.
The real problem was outside that file
Once the block was reduced to one line, I went looking for the directional assumptions that actually ship. I scanned the src tree (excluding generated data) for physical declarations. React inline styles are where all my layout lives, so this is where the answer is:
| declaration | count |
|---|---|
textAlign: 'left' | 15 |
textAlign: 'right' | 1 |
paddingLeft | 8 |
borderLeft | 8 |
left / right | 9 |
marginLeft / marginRight | 3 |
translateX | 2 |
| total physical | 46 |
marginInline / paddingInline / insetInline / InlineStart / InlineEnd | 2 |
Two. Both are insetInlineEnd on absolutely positioned badges. So the ratio is 46 to 2, and — this is the part that stings — the two prove that the logical syntax works fine in React. insetInlineEnd is just a camelCase property. There was never a tooling reason to write the other 46 the physical way. It was habit.
In the stylesheet the number is smaller but the ratio is the same: 2 physical declarations, 0 logical ones.
Note that not every physical value is a bug. Eight of my nine left/right values are direction-agnostic: four left: 50% with a negative margin-left to center an element, and two left: 0; right: 0 pairs that stretch a bar across the full width. Only one is asymmetric — a single left: 64% keyframe waypoint inside a decorative animation. This is why I prefer counting over grepping: the count gives you the surface area, the inspection tells you which entries matter.
What the quote bars look like in Arabic
The eight borderLeft + paddingLeft pairs are the ones I actually saw on screen. They are all the same pattern:
<div style={{ borderLeft: '2px solid rgba(212,168,67,0.4)', paddingLeft: 12 }}> Seven in one long-read screen, one more on a name detail card. In English this draws the familiar quote bar: a gold rule on the left, 12px of breathing room, text to the right of it. In Arabic the text starts at the right edge, so the gold rule ends up on the far side of the paragraph — visually detached from the thing it is supposed to anchor, and the 12px gap is now on the wrong side of the text as well. The block still reads; it just looks like a mistake, because the one element that carries "this is a quote" is pointing away from the reading edge.
The fix is two tokens:
<div style={{ borderInlineStart: '2px solid rgba(212,168,67,0.4)', paddingInlineStart: 12 }}> Same pixels in ten locales, correct edge in the eleventh. borderInlineStart has been in every engine I target for years; there is no excuse left except that I typed borderLeft out of muscle memory seven times.
Of 67 textAlign declarations, 16 are physical (15 left, 1 right) and 50 are center, which is direction-neutral. So roughly a quarter of my alignment decisions hard-code a side. Some of those are inside deliberately LTR blocks — a pinyin line, a numeric stat row — and are correct as written. The rest are just untranslated assumptions.
RTL also lives in the strings, not only in the CSS
The part that no amount of CSS fixes: the text itself. My Arabic locale file holds 202 translated strings and 5,361 Arabic characters, and here is what the same scan found in it:
- 0 strings use Arabic-Indic digits (٠١٢٣). 35 strings contain ASCII digits.
- 18 strings mix Latin letters into Arabic sentences — brand names,
Apple ID, product nouns. - 23 strings use the Arabic comma
،. 2 still use ASCII, ; : ? !. - 3 strings contain
→, which does not mirror.
The digit choice is a product decision, and Western digits are the common choice in modern Gulf and Egyptian interfaces, so having zero Arabic-Indic digits is defensible. What is not defensible is that it is a decision nobody made: it fell out of what the translator typed. Pick one convention, write it in the style guide, and audit against it — otherwise you will ship a screen with ٠١٢ next to 012 two releases from now.
The arrow is a subtler one. → is U+2192, which is bidi class ON (other neutral), and — unlike matched brackets — it has no mirrored counterpart in Unicode's bidi mirroring table. Browsers do not flip it for you. So a string like:
أدر اشتراكك في الإعدادات → Apple ID → الاشتراكات reads right-to-left ("manage your subscription in Settings, then Apple ID, then Subscriptions") while every arrow points right, which in RTL is backwards along the reading direction. The sentence tells you to go left, the punctuation says right. Either reverse the arrow in the Arabic string (←), or replace it with a non-directional separator like · or > wrapped in a <bdi>.
Same family of problem, one level down: in one string I interpolate a rank as المرتبة #${rank}. # is also neutral, so it takes its direction from context, and depending on what surrounds it you get #96 or 96#. The fix is boring and reliable — wrap the number:
<span dir="ltr">{rank}</span> The suspicious thing that turned out to be fine
I had flagged 29 letterSpacing declarations as an Arabic risk. Arabic is a cursive script: letters join, and in some engines letter-spacing is applied between joined letters, which visually tears words apart. It is a real phenomenon and I have seen it in the wild.
So I measured it instead of assuming, with a 28px system font:
| text | letter-spacing: normal | letter-spacing: 1px | delta |
|---|---|---|---|
محمد (4 letters) | 65.46px | 65.46px | 0.00px |
mmmm (Latin control) | 91.49px | 95.49px | +4.00px |
Chrome applies letter-spacing to the Latin run and drops it on the cursive Arabic run — the width is identical to two decimal places, which means no extra advance was inserted between any of the four joined letters. Shaping is untouched. The 29 declarations cost me nothing.
I am keeping this in the post because it is the most useful result in the audit: I had a plausible theory, I could have spent an afternoon migrating 29 style objects to a conditional letterSpacing, and measurement said the entire refactor was unnecessary. It also gives me a baseline: if a future engine version starts applying spacing there, the width probe will catch it in one line.
What I have now
The block that started this is one line:
[dir="rtl"] { direction: rtl; } Everything else moved to logical properties in the components that needed them, and the remaining work is a list, not a vibe:
- Grep for
textAlign: 'left' | 'right',paddingLeft,paddingRight,marginLeft,marginRight,borderLeft,borderRight,left:,right:,translateX— in the stylesheet and in inline style objects. - For each hit, decide: symmetric (center,
0/0inset) → leave it; asymmetric → logical property. - Delete any
[dir=rtl]override for something the browser already handles (directioncovers alignment, flex order, and inline-start insets). - Audit the locale file: digit convention, punctuation convention, and every non-mirrored glyph (
→ ← ▸ •) inside translated sentences. - Wrap numbers interpolated into RTL sentences in
dir="ltr". - Declare an Arabic-capable font stack instead of inheriting the Latin one.
That last item is still open in my app. My --font-sans is five Latin-first families (-apple-system, SF Pro Text, Helvetica Neue, …) and my --font-cn is a Chinese chain (PingFang, Microsoft YaHei). Neither lists a single Arabic face, and the only @font-face I ship is a Chinese brush font. Arabic therefore renders in whatever the operating system substitutes — fine on iOS, different metrics on Android, and I have never measured it. My plan is an explicit Naskh/Kufi fallback in the stack plus, if the metrics drift too far, one self-hosted Arabic face; both are measurable before and after with the same width probe I used above.
The broader lesson is the one I keep relearning: "it looks right in the screenshot" is not a test. I looked at a screenshot in Arabic, saw the text on the right, and concluded RTL was done. Ten lines of CSS, two of them inert, one of them actively cancelling the browser. The screenshot could not tell me any of that, and offsetLeft told me all of it in under a minute.
I build NameSoul, a Chinese-name app whose past-life stories ship in 10 languages, Arabic included — which is how this audit happened in the first place.
