RTL Is Not a Translation Problem

If you serve Persian speakers — or any second language — i18n is a system problem, not a string problem. Most teams translate the strings and hope the layout survives. That is how you get a product that feels broken in your own market.

When I asked for Persian support on this site, I asked for it wrong at first. I said: add the language, flip the direction, use the right font. The result was a site where Persian pages existed but the design system did not. Breadcrumbs pointed the wrong way. Flex rows refused to mirror. Icons faced the wrong direction. It felt translated, not built.

Translation is a content problem. RTL is a design-system problem. Fix the second or the first is invisible.


IRANSansX: Not a Font, a Requirement

Persian has visual density English does not. The font had to carry it.

The site already ran Outfit for Latin and JetBrains Mono for code. Persian needed its own variable font, IRANSansX, loaded from local assets with a specific configuration: the DOTS axis set to 7.

Seven dots

IRANSansX has multiple dot configurations. DOTS 7 matches how Persian is actually read and printed today.

Weight mapping

Persian headings need heavier weights to match Latin visual mass: h1 at 800, h2 at 700, h3 and below at 600, via [dir="rtl"] rules.

The font was restricted to Arabic and Persian unicode ranges so it never competes with Outfit. Two fonts, zero overlap, one design system.

If the font is a bolt-on, the text will look like a bolt-on. Persian is denser; it needs the heavier axis.


Direction Is a Token, Not a Hack

Zero hardcoded ml/mr/pl/pr. Everything logical, everything mirrored.

The audit rule was absolute: no physical utilities anywhere. No ml, no mr, no pl, no pr, no left, no right. Every margin and padding uses the logical variants — ms, me, ps, pe — so direction flips automatically with dir="rtl".

01

Logical utilities ms/me/ps/pe/start/end everywhere. The same class works in both directions.

02

Nav centering Absolute centering with left-1/2 -translate-x-1/2 that survives direction changes.

03

Row reversal sm:flex-row becomes row-reverse in RTL so justify-between lands correctly.

The day you hardcode a margin for one direction is the day you ship a second site. Logical properties are the only path.


The Audit That Found the Cracks

Double-flips, unmirrored arrows, and code blocks that broke under bidi.

A line-by-line audit across all pages found the bugs that live in the details. The nav had a double flip: flex-row-reverse plus dir="rtl" cancelling each other. The footer had the same bug. Slider arrows pointed the wrong way. Code blocks — which must always stay LTR — were getting mangled by bidirectional text.

Nav double-flip

flex-row-reverse plus dir=rtl canceled out. Removed the manual flip; dir=rtl does the mirroring.

Code under bidi

pre and code forced LTR so code stays readable inside a Persian page.

RTL done right is invisible. When it is wrong, every Persian visitor feels the site was built for someone else.

The fixes were systematic, not guessed: every RTL-sensitive rule audited, every directional icon flipped with a shared rtl-flip class, every flex row verified in both directions. If you localize with AI, remember: the model can translate your strings, but it cannot redesign your layout. That part is yours.


Ship RTL the Right Way

If you are localizing for the Iranian market, start at the system, not the strings.

1

Design the font first. Persian needs its own variable font with the correct dot mode and weight mapping. A Latin font stretched into Persian is a bug.

2

Ban physical utilities. Grep for ml/mr/pl/pr/left/right and eliminate them. Logical properties are the contract.

3

Audit the details. Nav, footer, arrows, code blocks, breadcrumbs. The layout bugs hide in the components you touch least.

Persian support is a design-system project with a translation deliverable. Build it that way.

The site does not support Persian. The design system does.

A variable font, logical utilities, and an audit that found the double-flips. RTL is the default, not the exception.

See the bilingual architecture