Makes an Expo app correct in right-to-left languages on iOS and Android: screens mirror by themselves through logical styles, the layout direction is decided before the app starts and changes with exactly one guarded restart (never a loop), text reads in its own direction, physical surfaces and non-directional icons stay put, numbers show the language's digits, and a zero-dependency checker proves the code keeps it that way.
Rules that must hold
- Style with logical keys and build rows with
flexDirection: 'row'.marginStart/End,paddingStart/EndorpaddingInline,start/end,border*Start*/*End*radii and widths; never physical left/right keys in a style (Reanimated styles and typed style helpers included), neverrow-reverse, neverscaleX: -1on content. Why: React Native 0.86 does swap physical keys to start/end in a right-to-left layout, but only while a global, persisted native preference (swapLeftAndRightInRTL) is on, which any library can turn off; next to a logical key on the same element the swapped key wins in RTL only; react-native-web never swaps. A truly physical position (a drag thumb) istransform: [{ translateX }], neverleft. (physical-style-key,row-reverse,scale-x-flip) - Render text through
AppTextand align withalign="start" | "end". Only the design system's text style hook maps start/end totextAlignand setswritingDirection; only AppText importsText. Why:textAlign: 'auto'follows a text's first letter, not the layout, and React Native swaps'left'/'right'in a right-to-left layout, so'right'lands on the end (left) edge (verified on iOS). (text-align-literal,writing-direction,text-import) - Keep one layout-direction source. Only
src/i18n/direction.tstouchesI18nManager; layout decisions (icon flips, coordinate maps, navigators) readuseDirection()orreadLayoutDirection(), neverdirectionOf(language); text takes its language's direction throughTextLocaleProvider. Why:isRTLis fixed for the whole JS run, so between a language change and the restart the language and the layout differ. (i18nmanager,layout-direction) - Decide the direction before the app starts.
DirectionGateis the root layout's first, outermost element (only effect-free wrappers such asGestureHandlerRootViewaround it); the startup work with effects (database open, migrations, stores, providers) lives in its child. Why: a restart re-runs every module and startup step, so work done before the decision happens twice. (direction-before-render) - Change direction only through
restartForDirection(). It records the pending direction in the guard first, setsallowRTLandforceRTLtogether, awaits the optionalbeforeReloadhook (an app with audio passes() => services.audio.dispose(), toDirectionGate'sbeforeReloadprop too), and reloads once; call it from a mounted component after the language is saved. A flip that does not take becomes a reported give-up. Why: reloading during bundle evaluation crashed a Release build, and without the guard a failed flip restarts forever. (direction-module,reload-outside-direction,reload-at-module-scope) - Give
expo-localizationsupportedLocalesonly. NeversupportsRTLorforcesRTL(nor inextra). Why: the native module re-applies them from the device language at every launch and undoes an in-app choice (read in expo-localization 57.0.2's source). An app with no language choice of its own may opt into thedirectionSource: "device"profile incontracts/rtl.json: no restart machinery and no RTL flag needed (supportsRTLis tolerated but makes iOS follow the device language too),forcesRTLstill banned. (expo-localization-rtl) - A root drawn outside the app's providers sets its own text locale. The restart screen, the
root
ErrorBoundary(its fallback component, or the JSX it draws inline) and any other outside root render<TextLocaleProvider value={{ language, direction: directionOf(language) }}>, with the language their text is in (the crash fallback:languageNow(), the source of its copy). Why: without it AppText falls back to English, left to right, and Arabic text is laid out backwards in the Latin line height. (root-direction-provider) - Physical surfaces stay left to right. Maps, media scrubbers, drawing pads and boards built
from views sit in
PhysicalDirectionView(list them incontracts/rtl.json); content that reads in the language's direction opts in withmirrorsInRtl. Gestures and safe-area insets stay physical: take insets symmetrically, never as left/right style keys. Why: north does not move to the right in Arabic, and media time runs left to right. (physical-surface-ltr) - Flip only icons that point along the reading direction, inside
Icon.DIRECTIONAL_ICONSequals the directional names (back,forward,chevron,undo,redoby default) that exist in the icon set; play, clocks, checks and pictures never flip. Why: a call site always forgets, and a flipped play button is wrong. (directional-icons) - Show every number in the language's digits. Format with the tag from
localeTagFor(language, digits)(createNumberFormatter,{n, number}messages); neverString(n),.toString()or.toFixed()for display; storage keeps plain numbers. Why: Hermes ignores the numbering system without the polyfill and the tag, and CLDR defaults move. (number-to-text; i18n-strings-and-catalogs checksIntl.NumberFormatitself) - Isolate free text, never hand-write bidi controls.
t()andisolate()wrap interpolated free text in FSI…PDI; a whole left-to-right text in a right-to-left row is<AppText direction="ltr">; the person's own text (a task title) takesfirstStrongDirection(text). Why: marks and embeddings break on one platform and in copy, search and screen readers; iOS shifted a wrapped English line 4.4 pt when laid right to left. (bidi-in-source) - Prove the layout on screen, on both platforms. Jest renders with an explicit language and direction and cannot see layout; every changed screen is captured in a right-to-left language on an iOS simulator and an Android emulator. Why: mirroring, clipping and fonts only show on a device.
Workflow
- Detect. Read
src/i18n/languages.ts(i18n-strings-and-catalogs creates it): with no right-to-left language inRTL_LANGUAGES, rules 1, 2, 9 and 11 still apply (they keep a future right-to-left language cheap) and the checker notes that the direction rules do not apply. The prerequisites are i18n-strings-and-catalogs (languages, digits, bidi, the forced polyfills), design-system-structure (AppText,TextLocaleProvider) and architecture-and-boundaries (the composition root). Decide who sets the direction: the default is the app (a language setting, or the resolved language, decides it through the gate); an app that only ever follows the device language may declare"directionSource": "device"incontracts/rtl.jsonand skip step 2's gate and guard (read "An app that only follows the device" in the direction-switch reference first: Android then follows the device locale even when the app falls back). - First time: the direction layer. Read references/direction-switch.md.
Copy
templates/src/andtemplates/test/to the same paths, but never overwritesrc/i18n/languages.ts,digits.tsorbidi.ts: they are i18n-strings-and-catalogs' files and hold the app's ownLANGUAGESand numbering; copy them only when the app has none. The canonical composition root already hasdirectionGuard; an app whose own predates it adds it toServices,createDeviceServices(the kv-store adapter),createTestServicesand their test. Restructure the root layout as examples/root-layout.md shows:RootLayoutdecides (adapters and the saved language only),DirectionGaterendersAppRoot, which does everything with effects; give the crash fallback its ownTextLocaleProvider. Copytemplates/contracts/rtl.jsonwhen a default does not fit (physical surfaces, another icon set or root file). - Building or changing a screen or component. Read references/styling-and-mirroring.md and imitate examples/mirrored-row.md. Strings arrive translated, numbers arrive formatted.
- Numbers, mixed-direction text, fonts. Read
references/digits-bidi-and-text.md: format in the model
hook with
createNumberFormatter(localeTagFor(language, digits)); give the person's own text its first-strong direction; check a bundled right-to-left font against its list. - Language screens. Follow examples/language-choice.md: save
first, then
restartForDirectionwhen the direction changes (first run: on Continue; Settings: after a Restart / Later dialog). Stop audio and flush pending work before the reload. - Physical surfaces. Wrap each in
PhysicalDirectionViewand list its component name incontracts/rtl.jsonphysicalSurfaces; a Skia canvas mirrors (or not) in its own layout (skia-canvas-rendering). - Tests. Read references/testing-rtl.md. Keep the template tests
green; render through
test/test-app.tsx'slanguage(and layoutdirection) options (the canonical file has them) and give each new component with text or a row one right-to-left render. - Run the checks from the repo root, fix every
FAILline (each names the file, the rule and the fix) and rerun until it printsRESULT: PASS:node ${CLAUDE_SKILL_DIR}/scripts/check-rtl.mjs .(multi-package: add--app-dir apps/mobile). Thennpm run typecheck,npx eslint . --max-warnings 0andnpx jest --ci. - Look at it. Capture every changed screen in a right-to-left language on an iOS simulator and
an Android emulator, each named (light and dark, phone and tablet, 200 % text), with the
recipes and the checklist in the testing reference; run the language-switch flow
(
templates/.maestro/flows/rtl/01-language-switch.yaml) after any change to the language screens or the startup. - Report what changed, which checks passed, which SKIP or note lines remain and who creates their targets, and what was verified on which platform (Android restart behaviour is not yet device-verified by this skill: say so until you have run it).
Definition of done
- No physical left/right style key,
row-reverse,textAlignliteral, hand-setwritingDirection,Textimport orscaleX: -1outside their one allowed file. - With a right-to-left language and the default
directionSource(app):src/i18n/direction.tsis the onlyI18nManageruser, forces both flags and records the guard before its single reload; the root layout rendersDirectionGatefirst and outermost and does no startup work with effects in the same component; no reload runs at import time or bypassesrestartForDirectionfor a language change. -
expo-localizationhassupportedLocalesand nosupportsRTL/forcesRTL; navigators andDirectionProviderfollow the layout; the restart screen and the crash fallback mount their ownTextLocaleProvider. - Physical surfaces sit in
PhysicalDirectionView;DIRECTIONAL_ICONSis exactly the directional icons that exist; numbers are formatted with the locale tag; free text is isolated and no bidi control is hand-written. - The template tests pass (
npx jest src/i18n src/services/direction-guard src/ui test/direction-guard --ci), and each new component with text or a row has a right-to-left render. - Right-to-left screenshots of every changed screen on iOS and Android show mirrored rows, start-aligned text, unflipped physical surfaces and icons, the language's digits, nothing clipped at 200 %.
- Expected SKIP lines only while their target is not created yet:
SKIP src/i18n/languages.ts [direction-module] not yet created (i18n-strings-and-catalogs creates it),SKIP src/i18n/languages.ts [direction-before-render] not yet created (i18n-strings-and-catalogs creates it),SKIP src/app/_layout.tsx [direction-before-render] not yet created (expo-project-bootstrap creates it). Note lines (physical-surface-ltr: no physical surfaces declared, the no-right-to-left-language note, thedirectionSource is "device"note) are information, not problems. -
node ${CLAUDE_SKILL_DIR}/scripts/check-rtl.mjs .printsRESULT: PASS
Anti-patterns
- "Fixing" RTL with
row-reverse,isRtl ? … : …or swappedmarginLeft/marginRight. React Native already swaps left and right in a right-to-left layout, so it double-flips. Userowand logical keys. left: xfor a drag thumb or scrubber. React Native swaps it tostartin Arabic, so the thumb runs backwards. Usetransform: [{ translateX: x }]or aPhysicalDirectionView.paddingLeft: insets.left. The swap moves the notch padding to the wrong side; usepaddingInline: Math.max(gutter, insets.left, insets.right)orSafeAreaView edges.textAlign: 'right'for Arabic. In a right-to-left layout'right'is the end edge, so the text lands on the left. UseAppText align.reloadAppAsync()(orUpdates.reloadAsync()) after a language change, at import time, or from an arbitrary module. It skips the guard and the forced direction, and at import time it crashes Release builds. UserestartForDirectionfrom a press handler or an effect.- Opening the database or hydrating stores in the component that renders the gate. A restart repeats it; move it into the gate's child.
- Deriving the layout direction from the language (
DirectionProvider direction={directionOf(language)}around the app, a navigator'sdirectionfrom the language). Icons and navigation flip before the rows do; followreadLayoutDirection(). - A crash or restart screen without its own
TextLocaleProvider. Its Arabic text is laid out left to right with Latin line heights. supportsRTL: true"to enable RTL". React Native allows RTL by default; the flag makes the device language overwrite the in-app choice at every launch.- Flipping a whole map, a media scrubber, a clock or a play icon. Only reading-direction arrows flip; a surface that mirrors maps its coordinates.
String(count)or a hard-coded locale inIntl.NumberFormat. Latin digits in every language.- Pasting RLM, LRM or embeddings to nudge text into place. Isolate free text, use
directionfor whole texts. - Trusting a green Jest run for layout. Jest has no layout engine and reports left to right; only screenshots show mirroring.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
| references/direction-switch.md | Verified facts, the modules, deciding first in the root layout, the three flows, text vs layout direction, navigation, roots outside the providers, expo-localization, Android specifics, follow-the-device apps, React Navigation appendix, multi-package | Workflow steps 2 and 5 |
| references/styling-and-mirroring.md | Logical keys, rows and grids, text alignment, safe areas, which icons flip, the design system's RTL checklist, physical surfaces, gestures | Workflow steps 3 and 6 |
| references/digits-bidi-and-text.md | Digit tags and outputs, numbering systems per language, numbers outside messages, the digits setting, isolation, a whole LTR text in an RTL row, the person's own text, Android first-strong, fonts for RTL scripts | Workflow step 4 |
| references/testing-rtl.md | What Jest sees, the tests to keep, right-to-left renders, iOS simulator and Android emulator recipes (named devices), the Maestro flow, screen readers, the screenshot matrix | Workflow steps 7 and 9 |
| examples/root-layout.md | The root layout, app root, composition-root and crash-fallback changes, verified in the reference app | Workflow step 2 |
| examples/mirrored-row.md | A task row that mirrors, keeps the title's own direction, its test and the wrong versions the checker catches | Workflow step 3 |
| examples/language-choice.md | The Settings language model hook: save, ask, restart once | Workflow step 5 |
templates/src/i18n/ |
direction.ts, direction-plan.ts, direction-gate.tsx, direction-restart.tsx, direction-context.tsx (synced; this skill owns the canonical copy), create-number-formatter.ts, text-direction.ts, each with its test; languages.ts, digits.ts, bidi.ts synced from i18n-strings-and-catalogs (do not edit here) |
Workflow step 2 (copy) |
templates/src/services/direction-guard/ |
The guard port, the expo-sqlite/kv-store adapter, the fake and its test (synced; this skill owns the canonical copy) |
Workflow step 2 (copy) |
templates/src/ui/ |
PhysicalDirectionView and its test |
Workflow steps 2 and 6 (copy) |
templates/test/direction-guard/ |
The adapter's test on kv-store's real SQL over node:sqlite (synced; this skill owns the canonical copy) |
Workflow step 2 (copy) |
templates/contracts/ |
rtl.json (the app's direction facts and the directionSource profile, every key optional) and its JSON Schema |
Workflow step 2, when a default does not fit |
templates/.maestro/flows/rtl/01-language-switch.yaml |
Maestro flow: switch to a right-to-left language, one restart, data kept | Workflow step 9 (copy, fill the testIDs) |
scripts/check-rtl.mjs |
Checks the 18 rule ids named in the rules above, across the app source (routes included), config plugins and the app config | Workflow step 8, after every layout change |
scripts/selftest.mjs |
Proves the checker passes the templates over a small Tasks app and catches each planted bug | After changing the checker or a fixture |
scripts/check-lib.mjs |
Shared script helper, synced (do not edit here) | Never by hand |
scripts/lib/ |
The source scanner and the literal reader the checker uses, synced (do not edit here) | Never by hand |
assets/shared.json |
Declares the synced files | When adding a shared file |
assets/template-placeholders.json |
Tasks-app testIDs that fill the Maestro flow when the templates are verified | When changing the flow's placeholders |
tests/bases/app/ |
The small Tasks app the fixtures start from | When a case needs a new base file |
tests/fixtures/ |
Overlay cases: good, one planted bug per rule, SKIP and note cases, the device profile, multi-package, bad input | When adding a rule |
Related skills
i18n-strings-and-catalogs- texts, keys, plurals,t(), the polyfills, languages, digits and bidi helpers.design-system-structure-AppText,TextLocaleProvider, fonts and tall-script line heights.architecture-and-boundaries- the composition root the guard joins, nothing at import time.settings-and-preferences- the saved language and digits settings and their screens.error-handling-typed-results- the root crash fallback that needs its own text locale.skia-canvas-rendering- canvas surfaces that mirror in their layout mapping.accessibility-as-code- screen-reader order, reduce motion, 200 % text.unit-and-component-tests-test/test-app.tsxand the render helpers.e2e-maestro- running the language-switch flow.ios-simulator-build- named simulators and the builds for the right-to-left captures.android-emulator-build- named emulators and the builds for the Android right-to-left captures.app-startup-and-bootstrap- the startup order aroundDirectionGate(RootLayout, gate, AppRoot).design-visual-parity- comparing right-to-left screens with the approved baseline.