Right-to-Left Layout

Use start and end instead of left and right, learn what flips by itself, mirror the icons that need it, and test with RTL forced.

SyntaxI18nManager.isRTL | I18nManager.allowRTL(allow: boolean) | I18nManager.forceRTL(forced: boolean)

Arabic, Hebrew, Farsi and Urdu read right to left. The whole interface flips with the text: controls sit on the right, lists indent from the right, a back arrow points right. React Native gives you one direction flag and a set of logical style names to do it with.

What flips on its own

When I18nManager.isRTL is true the layout engine reverses flexDirection: "row", so children lay out from the right. I18nManager.doLeftAndRightSwapInRTL defaults to true, so marginLeft also becomes marginRight — which is why left and right appear to work and then surprise you. Write marginStart, paddingEnd, start and end instead: they name the leading and trailing edge and read correctly in both directions. This is not the CSS cascade and there is no dir attribute; flexDirection still defaults to column, and writingDirection on Text is iOS only.

What does not flip

transform is untouched, so a chevron keeps pointing the same way; mirror it with scaleX: -1. Images never mirror. shadowOffset.width does not flip either. Leave clocks, checkmarks, digits, logos and media playback controls as they are.

Testing

I18nManager.allowRTL(true) and I18nManager.forceRTL(true) persist across launches and apply only after a full restart, which is what Updates.reloadAsync() is for. Alternatively set forcesRTL: true on the expo-localization config plugin and rebuild.

Example

Example · tsx
import { I18nManager, StyleSheet, Text, View } from "react-native";

type RowProps = { label: string; value: string };

function Row({ label, value }: RowProps) {
  return (
    <View style={styles.row}>
      <Text style={styles.label}>{label}</Text>
      <Text style={styles.value}>{value}</Text>
      {/* shorthand for: I18nManager.isRTL ? styles.mirrored : null */}
      <Text style={[styles.chevron, I18nManager.isRTL && styles.mirrored]}>{">"}</Text>
    </View>
  );
}

export default function SettingsList() {
  return (
    <View style={styles.screen}>
      <Row label="Language" value="English" />
      <Row label="Region" value="United Kingdom" />
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, paddingTop: 48, paddingHorizontal: 16 },
  row: {
    flexDirection: "row", // reverses by itself when isRTL is true
    alignItems: "center",
    paddingVertical: 14,
    borderBottomWidth: StyleSheet.hairlineWidth,
    borderBottomColor: "#d0d7de",
  },
  label: { flex: 1, fontSize: 16 },
  // marginEnd, not marginRight: it resolves to the trailing edge in both directions
  value: { fontSize: 16, color: "#57606a", marginEnd: 8 },
  chevron: { fontSize: 16, color: "#8c959f" },
  // transform is never flipped for you, so the glyph is mirrored by hand
  mirrored: { transform: [{ scaleX: -1 }] },
});

When to use it

  • Ship an Arabic build where every screen flips without rewriting the layouts
  • Mirror a back chevron so it points the way the reader reads
  • Keep a notification badge on the trailing corner of an avatar in both directions
  • Force RTL in a test build and walk every screen before a translator sees it

More examples

Matching the layout direction to the locale, then restarting

A screen printing the direction it is running in, with the direction decision at the app entry. You need this when the user can choose a language inside the app, or when direction must follow the device. The guard matters twice over: without it the app reloads on every launch, and because forceRTL persists, a device left in the wrong state never recovers. The effect has an empty dependency array so it runs once after the first render, and the early return sits inside the callback, not around the hook, so hook order never changes.

Example · tsx
import { useEffect } from "react";
import { I18nManager, StyleSheet, Text, View } from "react-native";
import { getLocales } from "expo-localization";
import * as Updates from "expo-updates";

export default function App() {
  useEffect(() => {
    // textDirection comes from the OS locale: "ltr" or "rtl"
    const wantsRTL = getLocales()[0].textDirection === "rtl";
    if (wantsRTL === I18nManager.isRTL) return; // already correct, do not reload

    I18nManager.allowRTL(wantsRTL);
    I18nManager.forceRTL(wantsRTL);
    void Updates.reloadAsync(); // the flags apply only to a fresh start
  }, []);

  return (
    <View style={styles.screen}>
      <Text style={styles.text}>{I18nManager.isRTL ? "RTL" : "LTR"}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, alignItems: "center", justifyContent: "center" },
  text: { fontSize: 24 },
});

Declaring RTL support in the project config

This goes in app.json. These are native project settings written by a config plugin, so a JS reload does nothing: the values take effect on the next prebuild or EAS build. supportedLocales is the list the stores and the OS language picker see. Set forcesRTL to true in a throwaway test build to review every screen in RTL without changing the phone's language, then put it back to false before shipping.

Example · json
{
  "expo": {
    "plugins": [
      [
        "expo-localization",
        {
          "supportedLocales": {
            "ios": ["en", "ar"],
            "android": ["en", "ar"]
          },
          "supportsRTL": true,
          "forcesRTL": false
        }
      ]
    ]
  }
}

Trailing-edge badge, and a mirror helper

A blue avatar circle with initials, a red count pinned to its trailing corner, and an arrow glyph that points toward the leading edge in both directions. Writing end: -4 rather than right: -4 states the intent, so the badge stays on the trailing corner even if someone later turns the swap off with I18nManager.swapLeftAndRightInRTL(false). The shadow block shows two things that are not CSS: shadows are shadow* on iOS and elevation on Android, and the shadow offset does not mirror, which is why a horizontal offset looks wrong in one of the two directions.

Example · tsx
import { I18nManager, StyleSheet, Text, View } from "react-native";

type AvatarProps = { initials: string; badge: number };

// Returns a transform array, so it can be unit tested without rendering.
export function mirrorIfRTL(isRTL: boolean): { scaleX: number }[] {
  return [{ scaleX: isRTL ? -1 : 1 }];
}

export default function Avatar({ initials, badge }: AvatarProps) {
  return (
    <View style={styles.wrap}>
      <View style={styles.circle}>
        <Text style={styles.initials}>{initials}</Text>
      </View>
      {/* top/end pins the badge to the trailing corner in both directions */}
      <View style={styles.badge}>
        <Text style={styles.badgeText}>{badge}</Text>
      </View>
      <Text style={[styles.arrow, { transform: mirrorIfRTL(I18nManager.isRTL) }]}>{"<"}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  wrap: { width: 56, height: 56 },
  circle: {
    flex: 1,
    borderRadius: 28,
    alignItems: "center",
    justifyContent: "center",
    backgroundColor: "#1f6feb",
    // iOS shadow; shadowOffset.width is NOT flipped in RTL, so keep it 0
    shadowColor: "#000000",
    shadowOpacity: 0.2,
    shadowRadius: 4,
    shadowOffset: { width: 0, height: 2 },
    elevation: 3, // the Android equivalent of the shadow* props - there is also boxShadow, which works on both platforms on the New Architecture
  },
  initials: { color: "#ffffff", fontSize: 18 },
  badge: {
    position: "absolute",
    top: -4,
    end: -4,
    minWidth: 20,
    paddingHorizontal: 5,
    borderRadius: 10,
    backgroundColor: "#cf222e",
    alignItems: "center",
  },
  badgeText: { color: "#ffffff", fontSize: 12 },
  arrow: { fontSize: 20, color: "#57606a" },
});

// Output: mirrorIfRTL(true) -> [{ scaleX: -1 }]
// Output: mirrorIfRTL(false) -> [{ scaleX: 1 }]

Discussion

  • Be the first to comment on this lesson.