Building Bilingual React Apps: Arabic & English with RTL Support

TL;DR

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.

Why RTL Is More Than direction: rtl

Most 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.

The Core Challenges

1. Layout Mirroring

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.

2. Icons That Have Direction

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.

3. Font Loading

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.

4. Mixed Content

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.

5. Forms, Inputs, and Numbers

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.

6. Animations and Transitions

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.

My Approach

I use a LanguageProvider context that:

  • Stores the current language in React state
  • Provides a t() function for translations
  • Sets dir and lang on the document root via a side effect
  • Loads the appropriate font on language change
  • Persists the user's choice in localStorage and reads it back on page load
  • A 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.

    Testing RTL

    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:

  • A single short Arabic word in a tight container - does it overflow?
  • A long Arabic sentence with embedded English term - does the term render correctly inline?
  • An email or URL inside Arabic copy - does it stay LTR?
  • A number with Arabic surrounding text - does it stay readable?
  • A form with mixed-direction fields - does each field face the right way?
  • Switching languages mid-session - does the layout reflow cleanly with no visible jank?
  • 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.

    Real Bugs I've Hit (So You Don't Have To)

  • A "send message" button with an arrow that pointed the wrong way in Arabic - caught by a user, not by me, three weeks after launch
  • An Arabic font that loaded with a 400 KB file even though the active language was English, because the preload was unconditional
  • A toast notification that animated in from the right in both languages, which made it feel correct in English and wrong in Arabic
  • A form field for phone numbers that flipped its placeholder around the +90 country code, producing "90+ سيلطس" instead of "+90 ..."
  • A dropdown menu that opened to the left of its trigger in Arabic, off-screen, because the absolute positioning used left: 0 instead of inset-inline-start: 0
  • Every one of those bugs was a one-line fix. The work was finding them.

    Key Takeaway

    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.