Mirroring your UI is not RTL support
Setting dir="rtl" flips the layout. It says nothing about what the Unicode Bidirectional Algorithm is going to do to your phone numbers.
Setting dir="rtl" moves the sidebar, right-aligns the text, and flips the arrows. That part is easy. Flexbox already knows how to reverse itself.
The part that isn't easy is that text has its own directional algorithm. It runs on every string your browser paints, in every locale, including English. Setting dir doesn't configure it. It just finally gives it something to do.
The algorithm is specified in UAX #9, about sixty pages of it. The premise is that a string doesn't have a direction. Every character has a direction, and the algorithm resolves them into runs, gives each run a numeric level, and reverses the odd ones.
Character classes
Every character gets sorted into a category first. Latin letters are strong left-to-right. Arabic and Hebrew letters are strong right-to-left. Digits are weak rather than strong, which matters more than it sounds like it should.
Then there are neutrals: spaces, punctuation, brackets, dashes. A neutral has no direction of its own. It takes one from whatever sits on either side, and when the two sides disagree it falls back to the direction of the paragraph.
Bidi resolution
Logical order: the bytes you stored
Resolved embedding level
Visual order, after the level 2 runs are reversed
Paragraph level 1, right-to-left. Even levels run left-to-right, odd levels run right-to-left.
Start with the phone preset. The Arabic sits at level 1. The digits sit at level 2, one deeper, running left to right inside a right-to-left run, because numbers read left to right in every script. That's rule I2.
Where the hyphen goes
Look at 555-0123 again. The hyphen resolved to level 1, with the Arabic, not with the digits.
A hyphen is a European separator. Rule W4 fuses a separator into a number only when there's a European number on both sides. But rule W2 already reclassified those digits as Arabic numbers, because they follow an Arabic letter. So W4 doesn't match, W6 demotes the hyphen to a neutral, and it resolves into the surrounding right-to-left run.
That leaves 555 and 0123 as two separate left-to-right islands inside a right-to-left run. Islands in a right-to-left run get laid out right to left.
The two halves of the phone number swap. Not the digits inside each half. The halves. Date ranges and version numbers do the same thing.
Switch to the plus preset for the other half of this. A leading + has nothing to its left, so it never fuses either, and it ends up on the far side of the number.
Neither of those is a browser bug. It's the spec running correctly on input nobody considered. If my implementation and your browser disagree in that demo, the browser is right.
Interpolation
You won't hand-type mixed-script strings. You'll interpolate them.
<p>Latest comment: {comment.body}</p>Fine in English. Broken the moment comment.body is Arabic.
Interpolating a foreign-direction string
Latest comment: مرحبا!
Latest comment: مرحبا!
Identical characters in identical order. Only the isolation differs.
The exclamation mark ends an Arabic sentence, so it belongs on the left of the word.
The exclamation mark is the thing to watch. Arabic sits to its left and the end of the paragraph sits to its right. Rule N1 needs both sides to agree before it commits, and a right-to-left run on one side and nothing on the other don't agree, so N2 hands the neutral to the paragraph direction instead. The paragraph is English, so the exclamation mark resolves to level 0 and renders to the right of the Arabic. In a right-to-left sentence that position is the front, not the end.
Isolate the same string and the punctuation lands correctly, because inside the isolate the paragraph direction is decided by the Arabic rather than by the English around it.
Watch what a trailing timestamp would do to this, though. Add · 2 minutes ago after the comment and the exclamation mark stops resolving to level 0 entirely, because rule N1 counts numbers as right-to-left. Now both sides of the neutral run agree, the whole run joins the Arabic at level 1, and the digit and the separator get reordered to the left of the word. Two different bugs from one missing <bdi>, and which one you get depends on what you happened to put after the interpolation.
Isolation
Isolation tells the algorithm that a substring resolves on its own and doesn't negotiate with its neighbors.
{/* dir="auto" is already the default on bdi */}
<bdi>{comment.body}</bdi>.comment-body {
unicode-bidi: isolate;
}// When there's no element to hang it on:
// FIRST STRONG ISOLATE ... POP DIRECTIONAL ISOLATE
const isolated = `${value}`Any string you didn't author counts as foreign-direction. Usernames, filenames, search queries, place names, error text relayed from an API. Isolate at the boundary.
Override characters
Some characters don't participate in the algorithm. They override it. U+202E, RIGHT-TO-LEFT OVERRIDE, forces everything after it into right-to-left order regardless of what those characters are.
One invisible character
15 characters stored, 14 of them visible.
This is a real filename rendered by your browser, not a picture of one.
One file, reporting one name to your UI and a different name to the filesystem. Malware distributors have used this to ship executables that render as documents for well over a decade. It works anywhere a name you didn't create gets rendered: file lists, chat attachments, commit authors, download managers.
// LRE, RLE, PDF, LRO, RLO, the isolates, and the two marks
const BIDI_CONTROLS = /[--]/g
const safeName = (name: string) => name.replace(BIDI_CONTROLS, '')Strip on display, then apply your own isolation. Leaving the caller's isolation in place and trusting it is the same mistake in a nicer hat.
Logical properties
Every left in a stylesheet claims that reading starts on the left.
| Physical | Logical |
|---|---|
margin-left | margin-inline-start |
padding-right | padding-inline-end |
left / right | inset-inline-start / inset-inline-end |
text-align: left | text-align: start |
border-top-left-radius | border-start-start-radius |
float: left | float: inline-start |
width | inline-size |
text-align is the one people still get wrong after converting everything else. Flipping left to right looks correct until a Latin string appears inside the page: a product code, a URL, a name. start resolves per element, so that string aligns the way it should. right is a hardcode wearing a translation's clothes.
In Tailwind that's ms-* and me-* instead of ml-* and mr-*, plus ps-*, pe-*, start-*, end-*, and text-start. The rtl: variant covers the few cases that really do need a direction-specific override. Reaching for it often means you're patching physical properties rather than replacing them.
Icons
A global scaleX(-1) on every icon is worse than doing nothing. Doing nothing leaves the arrows wrong. Flipping everything leaves the clocks wrong.
Which icons mirror
Back
mirrors
Points at whatever came before, and before is on the right.
Play
never
Media timelines run left to right in every locale on earth.
Clock
never
Clocks run clockwise everywhere. Mirroring invents a new physics.
Reply
mirrors
Directional: it aims back at the message it answers.
Check
never
A mark, not an arrow. Nothing about it points anywhere.
Progress
mirrors
Fills from the start of the line toward the end, and both flip.
Volume
mirrors
The waves radiate away from the cone, so the whole thing flips.
Note
never
Musical notation is left to right worldwide, including in Cairo.
Left-to-right baseline. Switch to rtl, then to flip all, and compare.
The test is whether the icon's meaning depends on reading order. A back arrow points at what came before, and what came before is now on the right, so it flips. A play button points along a media timeline, and media timelines run left to right everywhere, so it doesn't. Clocks run clockwise everywhere. Musical notation reads left to right everywhere. A checkmark isn't pointing at anything.
Flip that one and you've shipped a play button that reads as rewind.
Scoping the work
RTL usually gets estimated as a styling ticket. Flip the layout, translate the strings, a sprint or two.
What that misses is where direction actually lives. It belongs to every individual string, resolved character by character, by an algorithm that has been running the whole time.
In a pure-English UI it never had a decision to make. The first time you render a name you didn't write, it does.