@alialhajhussein/rtl-kit
v0.1.2
Published
Right-to-left done properly for the web and React Native: text direction, bidi isolation for IBANs, phones and numbers, digit normalization, bidi-spoofing detection, style mirroring, and React and React Native helpers.
Downloads
519
Maintainers
Readme
@alialhajhussein/rtl-kit
Right-to-left done properly for the web and React Native. Text direction from Unicode data, bidi isolation for the tokens that actually break, digit normalization for Arabic and Persian keyboards, bidi-spoofing detection, style mirroring, and React and React Native helpers.
Zero dependencies. Tree-shakeable. ESM and CommonJS with types. Runs in browsers, Node, edge runtimes and Hermes.
npm install @alialhajhussein/rtl-kit| Entry | For | Peer |
| --------------------------------- | ------------------------------------------------------------------------------------- | ----------------------- |
| @alialhajhussein/rtl-kit | Strings: direction, isolation, digits, bidi controls | none |
| @alialhajhussein/rtl-kit/style | Mirroring CSS-in-JS and React Native style objects | none |
| @alialhajhussein/rtl-kit/react | DirectionProvider, useDirection, <Isolate>, <Mirrored> | react |
| @alialhajhussein/rtl-kit/native | App direction switching, DirectionScope, autoTextStyle, <Isolate>, <Mirrored> | react, react-native |
Strings
import {
textDirection,
localeDirection,
isolate,
isolateTokens,
toLatinDigits,
findBidiControls,
isBidiBalanced,
stripBidiControls,
} from "@alialhajhussein/rtl-kit";
textDirection("مرحبا hello"); // "rtl" — first strong character, as dir="auto" decides
textDirection("1234"); // "neutral"
textDirection("1234", "rtl"); // "rtl" — with a fallback
localeDirection("fa-IR"); // "rtl"
localeDirection("pa-PK"); // "rtl" — Punjabi is written in Arabic script in Pakistan
localeDirection("pa-IN"); // "ltr"
isolateTokens("اتصل على +49 176 1234567 الآن");
// the phone number wrapped in LRI…PDI, so its digit groups keep their order
isolate(userName); // FSI…PDI: the plain-text <bdi>
toLatinDigits("٠١٧٦ ١٢٣٤٥٦٧"); // "0176 1234567" — validate input typed on an Arabic keyboard
isBidiBalanced("invoicefdp.exe"); // false — an unclosed override, the Trojan Source pattern
stripBidiControls("invoicefdp.exe"); // "invoicefdp.exe"What isolateTokens changes, and what it leaves alone
It isolates only tokens that browsers display out of order inside right-to-left text:
- Phone numbers that start with
+or(or have spaces between groups, such as+49 176 1234567,0176 1234567or(030) 1234-5678. Without isolation the digit groups appear reversed and the+lands on the wrong end. Groups joined only by-,.or/(0176-1234567,030/12345678) already form one left-to-right run and are left alone. - Signed numbers such as
-15%or+2,5 %. Without isolation the sign appears on the far side. CLDR's own Arabic and Persian number formats insert a left-to-right mark here for the same reason.
It deliberately leaves alone text that already displays correctly: IBANs, URLs and email
addresses (they start with a letter, so their digits resolve left to right by Unicode rule W7),
currency amounts (CLDR formats them in right-to-left order), unsigned numbers and dates, and
ranges. Text
already inside an isolate is skipped, so the function is idempotent. Both behaviours are
checked by measuring glyph positions in Chromium, Firefox and WebKit (e2e/).
For names, product titles and other user content, use isolate() or <Isolate>.
Direction data
textDirection uses the bidi classes of every code point in Unicode 17.0. localeDirection
uses Unicode script directions and CLDR 48 likely subtags and language aliases, so ckb, ur,
sd, pa-PK, uz-AF and Dari's prs are right to left, and ku or pa-Arab resolve by
their script. The data is bundled
rather than read from Intl.Locale#getTextInfo, which Hermes and older browsers lack, so every
runtime gives the same answer. UNICODE_VERSION and CLDR_VERSION are exported.
Style mirroring
import { mirrorStyle, directionalStyle } from "@alialhajhussein/rtl-kit/style";
mirrorStyle({ marginLeft: 8, textAlign: "left", borderRadius: "4px 0 0 4px" });
// { marginRight: 8, textAlign: "right", borderRadius: "0 4px 4px 0" }
mirrorStyle({ transform: [{ translateX: 10 }], shadowOffset: { width: 2, height: 1 } });
// React Native: { transform: [{ translateX: -10 }], shadowOffset: { width: -2, height: 1 } }
directionalStyle(style, direction); // mirrored only when direction is "rtl"It swaps left and right properties and flips values that encode a side: text-align, float,
four-value margins and paddings, corner radii (including the three-value form), shadow offsets,
translate, rotate and skew transforms, background-position (and left/right keywords in the
background shorthand) and cursors. It handles camelCase and kebab-case keys, nested selectors
and media queries, and React Native style arrays, transform arrays and shadow offsets. Its
return type has the renamed keys, so TypeScript sees marginRight after mirroring marginLeft.
Logical properties such as marginInlineStart are already direction-aware and stay as they are,
and url() values are never rewritten. Add /* @noflip */ to a value to keep it.
Its output matches rtl-css-js on that library's cases, except where rtl-css-js is wrong: it
leaves three-value radii unmirrored, rewrites url(left.png) to url(right.png), and ignores
React Native transforms and shadow offsets.
On the web, prefer logical CSS properties and dir where you can; mirrorStyle is for style
objects you don't control or can't rewrite. In an iOS or Android app running right to left,
React Native already swaps left and right margins, paddings, positions and text alignment, so
mirroring those styles again flips them back.
React
"use client";
import { DirectionProvider, useDirection, Isolate, Mirrored } from "@alialhajhussein/rtl-kit/react";
<DirectionProvider locale={locale} syncDocument>
<App />
</DirectionProvider>;
const direction = useDirection(); // "ltr" | "rtl"
<p>{t("greeting")} <Isolate>{user.name}</Isolate></p> // renders <bdi>
<Mirrored><ArrowRightIcon /></Mirrored> // flipped in rtlDirectionProvider renders no element. It takes direction or locale, or inherits from the
nearest provider. With syncDocument it sets dir and lang on <html> and restores them on
unmount. Mirror icons that point along the reading direction (arrows, chevrons, send, reply,
undo and redo). Leave checkmarks, media controls, clocks and logos as they are.
React Native
import * as Updates from "expo-updates";
import {
getAppDirection,
setAppDirection,
DirectionScope,
autoTextStyle,
Isolate,
Mirrored,
} from "@alialhajhussein/rtl-kit/native";
await setAppDirection("rtl", { reload: Updates.reloadAsync });
// { changed: true, restartRequired: false }
<Text style={autoTextStyle(message)}>{message}</Text> // aligned by the message's own direction
<DirectionScope direction="rtl">…</DirectionScope> // one RTL subtree, no restart
<Text>اتصل على <Isolate dir="ltr">+49 176 1234567</Isolate></Text>setAppDirectionpersists the direction throughI18nManager.allowRTLandforceRTL, regardless of the device locale. Native layout direction is read once at startup, so pass areloadfunction (Updates.reloadAsync, orDevSettings.reloadin development) or let it apply on the next launch. It reports{ changed, restartRequired }. Expo Go ignores forced directions, so test in a development build.autoTextStyle(text)is thedir="auto"that React Native lacks: user content aligns to the side its own direction starts on. React Native swapsleftandrightalignment in right-to-left layouts on both iOS and Android, and this accounts for it; on react-native-web, which does not swap them, it returns the physical side. For alignment that should follow the layout rather than the content, usetextAlign: "start"or"end".DirectionScopelays out a subtree right to left (or left to right) through thedirectionstyle, anduseLayoutDirection()inside it reports that direction. On the web,setAppDirectionupdates the document without a reload and re-rendersuseLayoutDirection.
On devices
The example app on the iOS 27 simulator and an Android emulator, in a left-to-right app and after
setAppDirection("rtl") and a restart, with @alialhajhussein/modern-arabic-font loaded. The
raw phone number shows the bug; the isolated lines show the fix.
| iOS, LTR | iOS, RTL | Android, LTR | Android, RTL | | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | | | | |
Size
Measured with esbuild (minified, gzip) by pnpm size, which fails CI above the budget:
| Import | gzip |
| ---------------------------- | ------ |
| textDirection | 1.5 kB |
| isolateTokens | 0.8 kB |
| localeDirection | 1.1 kB |
| toLatinDigits | 0.3 kB |
| everything in the root entry | 4.2 kB |
| /style | 1.6 kB |
| /react | 1.6 kB |
| /native | 2.6 kB |
Most of the root entry's size is the bundled Unicode and CLDR data.
Development
pnpm install
pnpm test # unit tests, including every Unicode code point against DerivedBidiClass.txt
pnpm build
pnpm test:e2e # glyph-position checks in Chromium, Firefox and WebKit (needs pnpm build)
pnpm size
pnpm generate:data # regenerate src/data from Unicode and CLDRexample/ is an Expo app that exercises the React Native entry and runs the core under Hermes.
Licence
MIT © 2026 Ali Al Haj Hussein. The generated tables in src/data are derived from the Unicode
Character Database and CLDR, © Unicode, Inc., under the Unicode License v3.
