Published on

RTL at Scale in a White‑Label React Native App: Beyond start/end

Thesis

Supporting RTL is not a theming toggle; it is a product capability that cuts across layout, gestures, virtualization, accessibility, and perf characteristics. If you only flip start/end, you will ship regressions that only surface under momentum, pagination, or low‑end Android thermal throttling.

When done well, RTL feels native: mirrored affordances, predictable anchoring, correct shaping and numeral systems, and zero surprises under stress. When done poorly, it looks almost fine in screenshots but breaks in motion — which is where users actually live.

Audience & constraints

  • React Native engineers shipping white‑label apps across LTR and RTL languages
  • Arabic/Hebrew support including contextual forms, numeric shaping, mixed BiDi paragraphs
  • Tight budgets on mid/low‑end Android devices where layout thrash and overdraw hurt

Context from mission

My client delivers a white‑label streaming app with full RTL. Core RN primitives were helpful, but lists, gestures/nav, and player controls demanded deeper design.

Large catalogs, snap carousels, and sticky headers uncovered anchoring bugs and measurement drift. Those do not show up in toy demos; they occur at scale under real content and flaky networks.

Fundamentals

Logical properties win: prefer paddingStart/marginEnd and flexDirection: 'row' | 'row-reverse' over hard left/right. Avoid manual mirroring unless you fully own the component lifecycle.

Use I18nManager only to set global direction once at app boot. Live flipping is dev‑convenience but requires a hard reload; do not try to simulate production orientation with ad‑hoc transforms.

Typography matters: Arabic shaping, ligatures, kashida elongation, and line‑breaking rules. Numeric shaping can be app‑level (always Latin) or locale‑aware; decide explicitly and keep consistent, else you will get mixed digits that look odd.

// bootstrap/i18n.ts
import { I18nManager, Platform } from 'react-native'

export function bootstrapDirection(rtl: boolean) {
  if (I18nManager.isRTL !== rtl) {
    I18nManager.allowRTL(rtl)
    I18nManager.forceRTL(rtl)
    if (Platform.OS !== 'web') {
      // Must reload the app process for layout to fully mirror
      // Here, delegate to native restart or an app shell relaunch
    }
  }
}

Opinion: global direction at boot is simple, predictable, and aligns with platform expectations. Trying to “hot‑swap” orientation mid‑session is possible but rarely worth the bugs 😅.

Layout and components

Flexbox directionality hides traps. row-reverse mirrors order and affects how justifyContent distributes space. Do not stack ad‑hoc scaleX(-1) unless you are isolating a leaf node; transforms invert gestures and accessibility order.

Absolute positioning must flip: insetInlineStart mental model helps; in RN, keep a tiny mapping utility for start/end -> numeric props when interacting with third‑party LTR‑only components.

Iconography: either ship mirrored SVGs or use transform mirroring for truly symmetric glyphs (chevrons are rarely symmetric). Asset pipelines need a *-rtl convention or automatic mirroring at build time; mixing both creates subtle inconsistencies.

Gestures and navigation

Back gesture and edge‑swipes mirror. On Android, the system back affordance is already RTL‑aware; inside the app, make carousel drags, dismissal swipes, and sheet pulls directionally coherent.

Guard gestures with direction. For example, a horizontal pan that opens a drawer should accept negative deltaX in LTR and positive in RTL. Combine with velocity thresholds to avoid accidental activation during flings.

Pagers/carousels must anchor visually at the leading edge. If you combine inverted lists and a pager, be explicit about page index math; otherwise your “next” becomes “+1” visually but “-1” logically.

// gestures/direction.ts
export function isForwardSwipe(dx: number, isRTL: boolean) {
  return isRTL ? dx > 0 : dx < 0
}

Note: making gestures direction‑aware is trivial in isolation, but coordinating with nested scroll views and momentum can get messy fast (ask me how I know 🥵).

Virtualized lists beyond stock

Stock FlatList/SectionList improved over the years, yet three classes of issues recur in RTL: scroll anchoring, async measurement drift, and sticky header offsets.

Anchoring: with horizontal carousels or inverted lists, the visual start is at the right edge. Use an anchoring model that computes contentOffset = contentWidth - viewportEnd - logicalOffset and keeps the item under touch stable during inserts.

Measurement: mixed fixed/variable item sizes cause drift during fast flings. Build a layout cache keyed by item key, prime it from getItemLayout where possible, and update lazily on onLayout without invalidating visible windows.

Sticky headers: translate offsets through the same RTL mapping and ensure z‑ordering does not fight with nested overflow: hidden containers.

// lists/rtlVirtualized.tsx
import React, { useMemo, useRef } from 'react'
import { FlatList, I18nManager, NativeScrollEvent, NativeSyntheticEvent } from 'react-native'

type Item = { key: string }

type Layout = { width: number; x: number }

type LayoutMap = Map<string, Layout>

function mapOffsetRTL(offsetX: number, contentW: number, viewportW: number) {
  if (!I18nManager.isRTL) return offsetX
  const max = Math.max(0, contentW - viewportW)
  return Math.max(0, Math.min(max, max - offsetX))
}

export function RtlList<T extends Item>(props: {
  data: T[]
  renderItem: (item: T) => React.ReactElement
  estimatedItemWidth: number
}) {
  const layoutRef = useRef<LayoutMap>(new Map())
  const estimated = props.estimatedItemWidth

  const getItemLayout = (_: T[] | null, index: number) => ({
    length: estimated,
    offset: estimated * index,
    index,
  })

  const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
    const { contentSize, layoutMeasurement, contentOffset } = e.nativeEvent
    const logicalX = mapOffsetRTL(contentOffset.x, contentSize.width, layoutMeasurement.width)
    // drive windowing based on logicalX...
  }

  return (
    <FlatList
      horizontal
      removeClippedSubviews
      windowSize={5}
      initialNumToRender={8}
      maxToRenderPerBatch={8}
      getItemLayout={getItemLayout}
      onScroll={onScroll}
      data={props.data}
      keyExtractor={(x) => x.key}
      renderItem={({ item }) => props.renderItem(item)}
    />
  )
}

Perf notes on low‑end Android: avoid nested percentage widths, pin image sizes, prefer resizeMode='cover' with known boxes, and keep window sizes small. removeClippedSubviews helps but can cause reparenting glitches if you rely on shadows; test it on 2–3 real devices.

Opinion: building a thin RTL‑aware wrapper around FlatList was faster than chasing every edge case with props. The extra indirection paid off under real traffic 🚀.

Media player UI

Scrubber direction must mirror: dragging “forward” should increase time in the visual forward direction. If your buffer bar uses absolute positioning from the left, flip it via a logical start calculation.

Timecodes: pick Latin digits or locale‑shaped digits; both are valid, but mixed rendering looks amateur. Captions and controls should respect safe areas; avoid clipping subtitles into rounded corners common on Android.

Gestures: double‑tap seek regions swap; left becomes “rewind” in LTR and the symmetric right does it in RTL (and vice‑versa). Keep the semantics stable; only the sides change.

Testing matrix

Snapshot testing must include RTL variants. Visual regression runs should diff both orientation and language; Arabic often reveals spacing issues even if Hebrew looked fine.

E2E with Detox: enable RTL before app launch and restart between suites to mimic real boot. Run a minimal happy‑path in Arabic/Hebrew across your top 3 device/OS combos; flakey only shows up there.

// e2e/rtl.setup.ts
import { device } from 'detox'

describe('rtl-boot', () => {
  it('enables RTL before launch', async () => {
    await device.launchApp({
      newInstance: true,
      languageAndLocale: { language: 'ar', locale: 'ar_EG' },
    })
    // Alternatively call a native module to set RTL, then relaunch
  })
})

Closing thought: RTL is less about flags and more about invariants. Define your anchoring model, gesture semantics, and measurement pipeline once, then implement consistently. The rest is disciplined plumbing. Small extra effort, large user impact — worth it, even if it felt a bit tedious on some days 🙃.

Last updated
© 2025, Devpulsion.
Exposio 1.0.0#82a06ca | About