Adding dir="rtl" to your HTML tag gets you about 60% of the way to an Arabic-ready app and gives you a false sense of being done. The other 40% is layout that mirrors correctly, icons that flip, mixed Arabic-English text that doesn't shatter the line, fonts that load in Arabic without choking the page, and forms that handle numbers and punctuation sanely. The fix is to lean on CSS logical properties (ms-, pe-, start-*), use Tailwind's rtl: variant for direction-aware classes, gate Arabic fonts behind the active language, wrap embedded LTR runs in <bdi>, and test with real Arabic content - never with mirrored lorem ipsum. Build this in from day one. Retrofitting it onto a finished LTR app takes longer than rebuilding the layout layer from scratch.
direction: rtlMost tutorials tell you to add dir="rtl" to your HTML tag and call it a day. That gets you maybe 60% of the way. The other 40% is where things get tricky - and where most apps targeting Arabic speakers fall apart.
I've shipped close to a dozen bilingual Arabic-English apps in the last three years - for Qobouli students, for Maka Media, for various clients across Istanbul and Riyadh. The first one took weeks of unscheduled cleanup because I treated RTL as a configuration flag instead of a design dimension. The last few took zero unscheduled cleanup, because I now build for both directions from the first line of code.
This is what I wish someone had handed me three years ago.
Every margin-left becomes margin-right. Every padding-left becomes padding-right. Every flex-row visually reverses. Every absolute-positioned element pinned with left: 0 ends up on the wrong side of the screen. If you've hardcoded directional values anywhere in your codebase, you'll need to change them all - and the worst ones are the ones you forgot, which only show up in a screenshot a client sends you a week after launch.
The fix: Use logical properties. In Tailwind, that means ms-4 (margin-inline-start) instead of ml-4, pe-6 (padding-inline-end) instead of pr-6, start-0 instead of left-0, end-2 instead of right-2. These automatically flip based on direction. In vanilla CSS, the modern equivalents are margin-inline-start, padding-inline-end, inset-inline-start, and so on.
The rule I now follow: the words "left" and "right" are bugs. Every time I see them in a class name or style rule, I treat them as a code smell that needs justification.
Back arrows, chevrons, progress indicators, next/previous buttons, send-message icons - anything that implies a direction needs to flip in RTL. A back arrow pointing left in English should point right in Arabic, because in Arabic "back" is to the right.
The fix: Add rtl:rotate-180 to directional icons in Tailwind, or write a CSS rule like [dir="rtl"] .directional-icon { transform: rotateY(180deg); }. For icon libraries, build a small <DirectionalIcon> wrapper that handles this once.
Be careful not to over-flip. A play button, a heart, a checkmark, a user avatar - these have no direction and should never flip. The trick is to keep a tight list of which icons are directional, and never auto-flip everything.
Arabic fonts are significantly larger than Latin fonts - often 3 to 5 times the file size, because Arabic needs many more glyph variations for ligatures and contextual forms. Loading both fonts upfront hurts performance on every page, even for users who only see one of them.
The fix: Load the Arabic font only when the active language is Arabic, and the Latin font only when English. Use font-display: swap to prevent layout shift, and preload the active font in the <head>. If you serve from a CDN, request only the glyph subset you actually use - for most apps you can drop file size by 70% by subsetting.
For my projects I typically use Cairo or IBM Plex Sans Arabic for Arabic, and Inter or Geist for English. I switch them dynamically based on the active language, and I never ship both at the same time.
Arabic text with embedded English technical terms - "تطبيق React مع TypeScript" - produces bidirectional text. The browser's bidi algorithm handles most cases well, but numbers, punctuation, parentheses, and quotation marks can flip in surprising ways, especially when they're adjacent to brackets or hyphens.
The fix: Wrap embedded LTR runs in <bdi> tags so the bidi algorithm treats them as isolated units. For numbers in Arabic UIs, decide explicitly whether you want Arabic-Indic digits (٠١٢٣) or Western digits (0123) and apply a consistent rule. Most Arabic-speaking users today prefer Western digits for technical content, but Arabic-Indic still feels right in editorial copy.
Form inputs deserve special attention. An input with type="email" should always be LTR, even on an Arabic page, because email addresses are LTR by nature. Same for URLs, phone numbers, and password fields. A bilingual form that gets this wrong looks broken even to users who can't articulate why.
The fix: Set dir="ltr" directly on inputs that hold LTR content, regardless of the page direction. Most form libraries support this out of the box once you tell them.
A slide-in animation that enters from the left in English should enter from the right in Arabic, otherwise it feels backwards. Same for swipe gestures, drawer pull-outs, and carousel navigation.
The fix: Use logical transform values where possible (transform: translateX() with a value derived from the direction), or define two animation keyframes - one for LTR, one for RTL - and select via the [dir] attribute.
I use a LanguageProvider context that:
t() function for translationsdir and lang on the document root via a side effectA simplified version looks like this:
function LanguageProvider({ children }: { children: React.ReactNode }) {
const [language, setLanguage] = useState<'en' | 'ar'>(() => {
return (localStorage.getItem('lang') as 'en' | 'ar') ?? 'en';
});
useEffect(() => {
document.documentElement.lang = language;
document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr';
localStorage.setItem('lang', language);
}, [language]);
return (
<LanguageContext.Provider value={{ language, setLanguage, t: makeT(language) }}>
{children}
</LanguageContext.Provider>
);
}
Translations live in JSON files (en.json, ar.json) loaded on demand or imported statically depending on whether the app is content-heavy. For small apps I import them statically; for larger ones I lazy-load by route.
Always test with real Arabic content. Mirrored lorem ipsum will not expose bidi issues, font-rendering problems, ligature breakage, or layout failures caused by genuinely longer Arabic words ("registration" is 12 characters; "التسجيل" is 7 but renders much wider in some fonts).
A reasonable test matrix:
I keep a small "RTL smoke test" page in every bilingual project: ten components rendered in both languages side by side. Before shipping, I look at it. It catches things I'd miss in production for months.
left: 0 instead of inset-inline-start: 0Every one of those bugs was a one-line fix. The work was finding them.
If you're building for Arabic speakers, RTL support isn't a nice-to-have - it's a requirement. And it's an order of magnitude easier to build in from day one than to retrofit later. Pick logical properties from the very first commit, write a smoke-test page, ship with real Arabic content, and you'll save weeks of post-launch cleanup.
The Arab market is enormous, underserved, and quietly hungry for products that respect their language. Get this right and you have a real edge.