The usual plan for adding Arabic is to extract the strings into a file, have them translated, set the direction to right-to-left and ship. That plan is correct about a third of the work and silent about the rest. What it misses is that direction is a layout property, not a text property.
- It changes where things are.
- Which side a panel slides in from.
- Which way an arrow points.
- What the word "next" means in a sequence of steps.
This site is bilingual and the Arabic is not a translation of the English layout; it is the same layout mirrored, which took real work in six specific places. What follows is that list, in rough order of how much trouble each one caused, because the order is not intuitive and the cheap fixes are not the ones you would guess.
One: physical properties everywhere
Every `margin-left`, `padding-right`, `left: 0` and `text-align: left` in a codebase is a statement that will be wrong in the other direction. There are usually hundreds of them, and fixing them one at a time by adding a right-to-left override doubles the size of the stylesheet and guarantees that the two directions drift apart as the design changes.
The fix is to stop writing physical properties. `margin-inline-start` means "the start edge in whatever direction this element reads", so it is a left margin in English and a right margin in Arabic, from one declaration. The same applies to `padding-inline`, `inset-inline-start`, `border-inline-end` and `text-align: start`. Once a codebase uses these consistently, the overwhelming majority of mirroring happens automatically and stays correct through later design changes.
The migration is mechanical and worth doing before writing any Arabic at all. In Tailwind the equivalent is to use `ms-`, `me-`, `ps-`, `pe-` and `start-`/`end-` rather than `ml-`, `mr-`, `pl-`, `pr-` and `left-`/`right-`, and the only hard part is that the physical versions keep working, so nothing fails loudly when somebody adds one. A lint rule that rejects the physical classes is the cheapest defence we have found.
Two: transforms do not mirror
This is the one that catches careful teams, because it survives the logical-property migration untouched. A `transform: translateX(-20px)` is physical and always will be — there is no logical equivalent — so every animation that slides, every carousel track, every drawer that comes in from the side and every hover effect that nudges an element will move the wrong way in Arabic while the static layout looks perfect.
There is no declarative fix, so the direction has to reach the animation code. The pattern we use is to read the direction once and multiply every horizontal offset by it: a `dir` value of 1 or -1 that every `translateX` is scaled by. It is slightly tedious and it is reliable, and the important discipline is that no horizontal transform anywhere in the codebase is written without that factor, because the one that forgets it is the one nobody notices for a month.
The same applies to `transform-origin`. A progress bar that fills with `scaleX` and an origin on the left will fill from the wrong end in Arabic — the bar grows away from where the reader starts, which reads as it emptying rather than filling. The origin has to follow the writing direction, and this is a one-line fix that is almost always missed because the bar still animates smoothly and nothing looks broken.
Three: icons that encode direction
Some icons carry a direction and some do not, and telling them apart requires a judgement that no automatic rule gets right. A "next" arrow must flip, because next is forward and forward is leftward in Arabic. A back chevron must flip for the same reason. But a play button does not flip, because media transport is a convention that is not tied to reading direction, and a mirrored play triangle reads as rewind to everyone.
Nor does a checkmark, a search glass, a logo, or a clock — and a clock is the interesting case, because its hands move in a direction that is a physical convention rather than a cultural one. The practical approach is to decide per icon once, record the decision in the icon set itself rather than in the component, and accept that this is a list somebody maintains rather than a rule somebody writes.
Arrows inside text
An arrow that sits inside a sentence or a button label is the most frequently wrong element on a bilingual site, because it is usually written as a literal character in the copy rather than as an icon with properties. A right arrow hard-coded into a translated string points away from the direction of travel, and it survives review because the reviewer is reading the words rather than looking at the glyph.
Four: numbers, dates and mixed runs
Arabic text containing a Latin product name, a version number or a URL produces a bidirectional run, and the browser resolves the order of those pieces with an algorithm that is correct more often than it is intuitive. The common failure is punctuation landing on the wrong side: a sentence ending in a parenthesis after a Latin word puts the bracket somewhere surprising, and a phone number with a leading plus sign can render with the plus at the wrong end.
The remedy is to isolate the foreign run rather than to fight the algorithm. Wrapping it in an element with `unicode-bidi: isolate` or using the `<bdi>` element tells the browser to treat that fragment as an opaque unit, which is almost always what was intended. This is worth doing automatically wherever user-supplied content appears, because a customer name in the other script is the case most likely to produce something visibly wrong.
Numerals are a separate decision and a cultural one rather than a technical one. Arabic-Indic digits and Western digits are both correct Arabic depending on the region and the context, and the right answer for a given audience is a question for that audience rather than for an engineer. What matters technically is that the choice is a single configuration value and not a hundred formatting calls, because it will be revisited.
| What breaks | Why it survives review | The fix |
|---|---|---|
| margin-left, padding-right, left | Static layout looks fine until mirrored | Logical properties, plus a lint rule |
| translateX in animations | It animates smoothly, just backwards | A direction factor of 1 or -1 |
| transform-origin on progress bars | The bar still fills, from the wrong end | Origin follows writing direction |
| Arrows written into copy | Reviewers read words, not glyphs | Arrows as icons with a flip flag |
| Latin names inside Arabic text | Only wrong for some inputs | Isolate the run with bdi |
| Shadows and asymmetric spacing | Looks like a design choice | Decide whether light has a side |
Five: the font is not interchangeable
Arabic typesetting has different metrics from Latin, and a size that is comfortable in one is frequently not in the other. Arabic letterforms connect, carry marks above and below, and have a smaller effective x-height relative to their point size, with the practical consequence that Arabic set at the same size as Latin usually reads smaller and tighter than intended, and the same line-height that is generous in English can clip diacritics.
So the type scale needs a per-language adjustment, usually a small size increase and a slightly looser line-height for Arabic. This has a knock-on effect that is worth anticipating: the same content takes a different amount of vertical space in each language, which means any layout that depends on two columns being the same height, or on text fitting inside a fixed box, needs checking in both. We found several of ours this way.
Arabic is also typically more compact than English for the same meaning, so translated copy tends to be shorter and bands of content end up visibly less tall. That is not a defect, and trying to pad the Arabic to match the English line count produces worse writing. It does mean that any visual target measured in one language is not a target in the other, which is worth stating explicitly before somebody files it as a bug.
Six: the things no property reaches
A few decisions are genuinely editorial and no amount of CSS settles them. Whether a shadow falls to the same physical side in both directions is one: light has a physical source, so a designer may reasonably say the shadow does not mirror, and a different designer may reasonably say the interface should be symmetric. Either answer is defensible; having no answer means the two directions drift.
Charts are another. A time axis running left to right is a convention strong enough that many Arabic publications keep it, while the labels and the legend mirror — producing a chart that is partly mirrored and correct. Deciding that per chart type, and writing the decision down, is the only approach we have found that survives more than one designer.
And the copy itself is the largest item of all. Arabic written as a translation reads as a translation, and users can tell immediately — the register is wrong, the idioms are literal, and the result signals that the Arabic interface is the secondary one. Writing the Arabic as Arabic costs more than translating it and it is the difference between a bilingual product and an English product with an Arabic mode.
Input fields, selection and the caret
Text input is where bidirectional support stops being a styling question. A field that correctly renders Arabic can still put the caret in the wrong place after a punctuation mark, select a range that looks discontinuous, or move the cursor the wrong way when the user presses the right arrow — because in a mixed run the visual order and the logical order diverge, and arrow keys follow one of them while the eye follows the other.
Most of this is the browser’s job and most of it works, provided the field is told what it contains. The failures cluster in the places where an application overrides the default: a masked phone number, a custom caret drawn in a canvas, a formatter that reinserts separators on every keystroke, an autocomplete that highlights a substring by index. Each of those assumes a left-to-right string and each breaks in a way that looks like a rendering bug.
The practical rule is that a field holding a known-direction value — an email address, a URL, an account number, a code — should declare that direction explicitly rather than inherit the page’s. An IBAN inside an Arabic form is a left-to-right string and will render with its segments in the wrong visual order if it is left to inherit, which is both wrong and dangerous in a field somebody is about to compare against a bank statement.
Search has to normalise the letters
Arabic search that compares strings byte for byte will miss most of what users type, because several letters have variants that users treat as identical.
- Alef with and without hamza.
- The two forms of ya at the end of a word.
- Ta marbuta against ha.
Someone searching for a name will spell it one of three defensible ways and expect all three to find the record, and an exact comparison finds one of them.
On top of that there are diacritics, which are optional in writing and almost always absent in what users type, and the tatweel — the stretching character used for justification, which carries no meaning but does change the bytes. A search index that does not strip those will fail on text copied out of a formal document, which is exactly the text people paste into a search box.
- Fold the alef forms together, hamza and all.
- Fold final ya and alef maqsura to one form.
- Fold ta marbuta and ha when they end a word.
- Strip the diacritics and the tatweel on both sides.
- Normalise the query exactly as you normalised the index.
The last item on that list is the one that goes wrong. Normalisation has to be applied identically to the stored text and the query, by the same code, or the index is normalised and the search is not and nothing matches at all. That means normalisation belongs in one function that both paths call, and it means re-indexing whenever that function changes — which is a migration, so it is worth getting the rules settled before there are two million rows.
The copy is shorter and the lines taller
Translated Arabic is usually shorter than its English source in characters and wider in rendered line height, and both of those move a layout that was tuned for one of them. A heading that balances on two lines in English lands on one in Arabic, which collapses the space the design was relying on; a body paragraph needs more leading to stay readable, which lengthens the column. Neither is a defect and both change the vertical rhythm.
Which is why both locales get measured
The consequence for process is simply that every layout check runs twice. Measuring only the source language and assuming the translation will behave is how a site ends up with an Arabic heading that overlaps the element below it on one breakpoint — a defect that is invisible to everybody who works in English and obvious to every user who does not. It costs one extra pass over the same page set and it catches a class of bug nothing else does.
It also argues for designing the constraints rather than the strings. A card that equalises its height against its row survives a translation that is forty percent shorter; a card with a hand-tuned fixed height does not. The same is true of headings sized with a maximum line count rather than a character budget. Where the layout expresses a rule, the translation is absorbed; where it expresses a measurement, the translation breaks it.
How to test both directions
Manual review in two languages does not scale and does not catch the transform bugs, because those require interacting with the thing rather than looking at it. What we run instead is a small set of automated checks against both locales: no horizontal overflow at four widths, which is where asymmetric spacing shows up; and a screenshot of each route in each direction, so a change that only breaks one of them is visible in review.
The overflow check is worth describing precisely because the obvious implementation is wrong. Looking for elements that extend past the viewport produces constant false positives, because a marquee or a carousel track legitimately does exactly that inside a container with `overflow: hidden`. The signal that actually means something is whether the document itself scrolls horizontally — comparing the root element’s scroll width against its client width — and nothing else.
- Migrate to logical properties first, with a lint rule after.
- Multiply every horizontal transform by a direction factor.
- Give transform-origin the writing direction too.
- Mark each icon as mirroring or not, in the icon set.
- Isolate foreign runs with bdi wherever input appears.
- Check scrollWidth against clientWidth, not element bounds.
The summary is that five of the six problems have a mechanical fix that can be applied before any Arabic exists, and the sixth — the copy, and the handful of editorial decisions around shadows and charts — is the one that determines whether the result feels like a product in that language. Teams usually budget for the translation and not for the five, which is the wrong way round: the five are cheap and skipping them is visible, while the translation is the expensive part and doing it well is what anybody actually notices.



