RTL support in React Native often seems straightforward: enable it, add translations, and let the framework flip the UI. For many apps, that approach works well.
However, while building a bilingual English–Arabic EdTech app for users in Kuwait, we quickly realised those assumptions didn’t always hold.
In this app, language is chosen inside the product, not dictated by the device. Arabic wasn’t a mode. It was a first-class experience, and the layout needed to reflect that from the ground up.
That decision didn’t come from theory; it came from the first time we opened the Arabic designs and realised something was fundamentally off.
When RTL Becomes a Layout Problem, Not a Translation Task
When we first heard the app needed to support both English and Arabic, it sounded straightforward. Add translations, wire up i18n, tweak textAlign for Arabic, and move on.
That assumption lasted until we opened the Arabic designs.
The back arrow was on the opposite side. Primary actions had shifted. Lists flowed differently. Even the way numbers sat inside sentences felt unfamiliar. Nothing was technically broken. But everything felt off, as if the UI had quietly turned around without us noticing.
That’s when it clicked: this wasn’t a translation problem.
Our mental model had started here:
textAlign: isArabic ? 'right': 'left'But the real questions we were suddenly asking looked very different:
- Should this entire row be reversed?
- Does this carousel scroll in the opposite direction?
- Where should the icon sit so the action feels natural to an Arabic reader?
- Why does this date look correct, but still feel wrong inside Arabic text?
RTL wasn’t a style toggle. It was a reading order problem. A spatial problem. A layout problem.
Arabic users don’t just read text from right to left.
They scan screens differently.
Icons, actions, spacing, and flow all follow that direction. Treating Arabic as “English with right-aligned text” was never going to work.
That was the moment our thinking changed. We stopped asking, “How do we translate this UI?” and started asking, “How does this layout need to behave when the reading direction itself changes?”
Everything that followed - the architecture, the components, and the helpers- grew out of that shift.
Why React Native RTL Breaks When It Follows the Device Language
Once we accepted that RTL was a layout problem, the next question was obvious: how should the app decide when to flip?
React Native’s default approach, as described in the React Native I18nManager documentation, is to let the device language drive layout direction. Enable RTL globally, and the framework mirrors the UI when the system language is Arabic (or any RTL language).
Code: The default React Native RTL switch
import { I18nManager } from 'react-native'
I18nManager.allowRTL(true)
I18nManager.forceRTL(true)That approach works well for apps that live entirely in one direction. But our app didn’t.
This was a bilingual English–Arabic app, and language selection happened inside the app. Users might keep their phone in English but prefer Arabic in the app (or the reverse). In those cases, OS-driven RTL created a subtle but serious mismatch.
We kept running into situations like this:
- The app language is English
- The device language is Arabic
- React Native flips the entire UI anyway
Nothing crashes. Nothing looks obviously broken. But the layout suddenly feels wrong.
Icons appear on unexpected sides. Navigation flows feel inverted. The interface no longer matches the language the user chose.
This OS-level behaviour was the core pain point. It wasn’t a bug in React Native, and it wasn’t a missing translation. It was a misalignment of responsibility: the OS was deciding layout direction for a product where language choice belonged to the app itself.
That realisation shaped an early architectural decision. Instead of trying to fight or patch over global RTL behaviour later, we chose to step away from it entirely and make layout direction an explicit, app-level concern.
This kind of decision-making is central to the way we approach mobile product engineering in our Mobile Development services, where React Native apps are designed with platform behaviour, user language preferences, and real-world UX constraints in mind from day one.
Everything after this point, from providers to components, was built around that choice.
App-Controlled RTL: Letting the App Language Drive Layout Direction
Once it was clear that OS-driven RTL couldn’t model our product correctly, the decision itself was surprisingly simple.
We stopped letting the system decide.
Instead of enabling React Native’s global RTL and reacting to its side effects, we made layout direction an explicit outcome of the language selected inside the app. If the user chose Arabic, the layout behaved as RTL. If they chose English, it behaved as LTR, regardless of the device’s OS language.
To make this reliable, we kept React Native’s global layout permanently LTR. All RTL logic lived inside the application layer. This gave us full control over when and where mirroring happened, without unexpected flips triggered by system settings.
Language preference itself was treated as user data. We persisted it locally using MMKV and synced it to the backend profile, so the same choice followed the user across devices. A LocaleProvider sat at the top of the app and kept language, translations, and layout direction in sync.
Code: Keeping global layout LTR and hydrating language from storage
useEffect(() => {
const preferred_language = storage.getString('preferred_language') as AppLocale | null
if (I18nManager.isRTL) {
I18nManager.allowRTL(false)
I18nManager.forceRTL(false)
}
const isSupported =
preferred_language &&
[AppLocale.English, AppLocale.Arabic].includes(preferred_language)
const effectiveLocale = isSupported ? preferred_language! : AppLocale.Arabic
i18n.locale = effectiveLocale
if (!isSupported) {
storage.set('preferred_language', AppLocale.Arabic)
setLocale(AppLocale.Arabic)
}
}, [locale])The guiding principle was straightforward:
Layout follows the app’s language, not the phone’s language.
This one decision removed ambiguity across the entire UI. From that point on, RTL wasn’t something we enabled or disabled globally; it became a predictable, app-controlled behaviour that every component could rely on.
Building an RTL-Aware Layout Component in React Native (LocalisedView)
Once layout direction became an app-level decision, the next challenge was practical: how to flip the UI where it actually mattered, without spreading RTL logic everywhere.
We didn’t need RTL awareness across the entire component tree. Most vertical layouts worked fine. The problem showed up in horizontal layouts. These are the places where element order carries meaning - back buttons, list items with icons, or buttons that combine an icon and label.
Instead of handling this inline on every screen, we introduced a small abstraction: an RTL-aware wrapper called LocalisedView.
Its responsibility was intentionally narrow:
- Read the current locale from
useLocale() - Decide whether the language is RTL
Code: Detecting whether the current locale is Arabic
const isArabicLocale = (locale?: string) => locale === AppLocale.Arabic- Apply
roworrow-reversefor horizontal layouts - Leave vertical layouts untouched
Code: LocalisedView - one RTL-aware wrapper for row layouts
const LocalisedView: React.ForwardRefRenderFunction<
View,
LocalisedViewProps
> = ({ style, children, direction = 'row', ...props }, ref) => {
const { locale } = useLocale()
const isRTL = isArabicLocale(locale)
const baseStyles: ViewStyle =
direction === 'row'
? { flexDirection: isRTL ? 'row-reverse' : 'row' }
: { flexDirection: 'column' }
return (
<View ref={ref} style={[baseStyles, style]} {...props}>
{children}
</View>
)
}
By centralising this logic, we avoided scattering conditionals like isRTL ? 'row-reverse': 'row' across the codebase. Whenever a layout needed to respect reading direction, we used LocalisedView instead of re-solving the problem.
This single building block handled most RTL layout needs and made directionality a deliberate, visible choice in the code, driven entirely by the app’s language state.
Designing RTL Layouts Using Start and End Instead of Left and Right
Even with layout direction handled, some UI elements still felt unbalanced in Arabic. Nothing was broken, but spacing felt subtly wrong.
The issue wasn’t RTL itself; it was how we thought about spacing.
Years of habit had us using marginLeft, marginRight, paddingLeft, and paddingRight. Those properties assume left equals start and right equals end - an assumption that doesn’t hold in RTL.
Code: The spacing style that looks fine in English but breaks subtly in RTL
const styles = StyleSheet.create({
item: {
flexDirection: 'row',
marginLeft: 12,
paddingRight: 16
}
})Locale-Aware Numbers, Currency, and Dates in RTL Apps
Even when layout and spacing were correct, numbers and dates still stood out in Arabic. They weren’t wrong; they just looked English.
Values like 1,200 or 12/03/2025 break the visual flow when dropped into Arabic text. The issue wasn’t translation; it was formatting.
The fix was to treat numbers and dates like text: they go through the locale layer.
We pushed all formatting logic into LocaleProvider and exposed helpers for common cases - numbers, currency amounts, and dates. Everything relied on Intl, selecting the correct locale and numeral system based on the active app language.
Code: Locale-aware number formatting (translateNumber)
const translateNumber = (
value: number,
options?: Intl.NumberFormatOptions,
decimals?: number
) => {
const lang = isArabicLocale(locale) ? 'ar-EG' : locale || 'en-US'
const numberFormat = new Intl.NumberFormat(lang, {
style: 'decimal',
...options,
...(decimals !== undefined && {
maximumFractionDigits: decimals,
minimumFractionDigits: decimals
})
})
return numberFormat.format(value)
}
Currency responsibilities were clearly split. Conversion happened in the backend. The app received a final amount and currency code and focused only on presenting it correctly. Arabic flows used Arabic numerals and labels; English flows relied on standard locale-aware formatting.
Code: Formatting amounts with currency (getAmountWithCurrency)
const ARABIC_CURRENCY_LABELS: Record<string, string> = {
KWD: 'دينار',
SAR: 'ريال',
AED: 'درهم',
USD: 'دولار'
}
const getAmountWithCurrency = (
amount: string | number,
currency: string = 'USD',
locale?: string
) => {
const numericAmount =
typeof amount === 'string' ? Number(amount) : amount
if (Number.isNaN(numericAmount)) return ''
const isRTL = isArabicLocale(locale)
if (isRTL) {
const lang = locale || 'ar-EG'
const formattedNumber = new Intl.NumberFormat(lang, {
maximumFractionDigits: 0,
minimumFractionDigits: 0,
numberingSystem: 'arab'
}).format(numericAmount)
const label = ARABIC_CURRENCY_LABELS[currency] || currency
// Arabic pattern: "<number> <currency label>"
// e.g. "١٠ دينار", "١٠ ريال", "١٠ دولار"
return `${formattedNumber} ${label}`
}
const lang = locale || 'en-US'
// LTR: let Intl decide symbol + placement (e.g. "KWD 10", "$10")
return new Intl.NumberFormat(lang, {
style: 'currency',
currency,
maximumFractionDigits: 0,
minimumFractionDigits: 0
}).format(numericAmount)
}
Dates followed the same pattern. Instead of manually building strings, the UI delegated formatting to helpers that respected both language and numeral system. This kept screens clean and ensured every value matched the user’s chosen language without duplication or conditionals.
Code: Date formatting as a helper (convertToDdMmYyyy)
export const convertToDdMmYyyy = (
isoString: string,
locale = AppLocale.Arabic
) => {
const date = new Date(isoString)
const options: Intl.DateTimeFormatOptions = {
day: '2-digit',
month: '2-digit',
year: 'numeric'
}
if (isArabicLocale(locale)) {
return new Intl.DateTimeFormat('ar-EG', {
...options,
numberingSystem: 'arab'
}).format(date)
} else {
return new Intl.DateTimeFormat('en-GB', options).format(date)
}
}
Code: Using the helper in UI code
const label = convertToDdMmYyyy(order.created_at, locale)Polishing RTL UX: Icons, Inputs, and Directional Details
By this point, the app felt solid in both English and Arabic. What remained were details - small, but immediately noticeable.
Directional icons were the most visible. In English, a back arrow points left and appears before the label. In Arabic, it should point right and appear after the text. Getting this wrong doesn’t break navigation, but it constantly reminds users that the UI wasn’t designed for their reading direction.
We handled this explicitly at the component level, rendering icons based on the active language so both direction and placement matched the reading flow.
Code: Mirroring a back row (icon direction + placement)
const { locale } = useLocale()
const isRTL = isArabicLocale(locale)
<LocalisedView>
{isRTL ? (
<>
<Text>{t('Back')}</Text>
<Icon name="chevron-right" />
</>
) : (
<>
<Icon name="chevron-left" />
<Text>{t('Back')}</Text>
</>
)}
</LocalisedView>
Text inputs needed similar care. Left-aligned fields felt natural in English but awkward in Arabic. Aligning input text and the caret, based on the app’s language, made forms immediately more comfortable, especially for multiline inputs.
Code: Inputs follow the app language too
const { locale } = useLocale()
const isRTL = isArabicLocale(locale)
<TextInput
style={[
styles.input,
{ textAlign: isRTL ? 'right' : 'left' }
]}
placeholder={t('Enter your name')}
/>
None of these changes was complex, but together they formed the final layer of polish. They turned an interface that merely supported RTL into one that genuinely respected it, where RTL stopped being a special case and became a first-class path through the app.
RTL Architecture Checklist: What We’d Do on Day One
Lessons we’d apply immediately if we were starting the same app again today. You can use this as your day-one setup checklist before building screens.
| Decision | Why It Matters |
| Make the layout follow the app language (not the OS) | Prevents unexpected UI flips and keeps directionality aligned with the user’s explicit choice. |
| Add a LocaleProvider from day one | Creates a single source of truth for language, layout direction, and formatting as the app grows. |
| Introduce an RTL-aware row component early | Ensures horizontal layouts don’t need retrofitting once RTL screens are added. |
| Design with start/end, not left / right | Avoids subtle spacing bugs that only surface in RTL on real devices. |
| Push numbers, currency, and dates into the locale layer | Keeps formatting consistent and prevents ad hoc logic across screens. |
| Scope language support intentionally | LTR and RTL languages scale cleanly; non-horizontal scripts require separate design decisions. |
Biggest takeaway:
Conclusion: Designing Two First-Class Layout Directions in One App
The most important shift in this project wasn’t an API choice or a clever abstraction. It was stopping ourselves from treating Arabic as “English, but mirrored.”
This app doesn’t rely on conditional tweaks to one layout. It treats LTR and RTL as two first-class reading directions, each designed deliberately. Once that mindset clicked, the architecture followed naturally - app-controlled direction, predictable components, and a locale layer that handled more than just strings.
RTL in React Native doesn’t fail because the framework is weak. It fails when layout direction is treated as an afterthought. When language, layout, and formatting are aligned from the start, RTL stops being fragile and simply works - quietly, consistently, and exactly how a production app should.
If you found this post valuable, I’d love to hear your thoughts. Let’s connect and continue the conversation on LinkedIn.
Shravani Khatri
SDE3
Shravani is a Frontend Engineer working across React and React Native who takes features from design to production with a focus on clean UI, reliable quality, and scalable architecture. With a QA background, she has a sharp eye for details and edge cases.



