RTL & Bidirectional Layout Engineering
When you switch a UI to Arabic or Hebrew and the back-arrow still points left, the sidebar stays glued to the wrong edge, and a phone number renders as +1 (555) 321 with the digits scrambled, you are not looking at five bugs — you are looking at one missing dir attribute and a stylesheet full of physical margin-left rules. This page is the implementation reference for right-to-left and bidirectional layout: setting direction once at the document root from the resolved locale, replacing physical CSS with logical properties, handling the Unicode Bidirectional Algorithm with isolation, and mirroring the icons that must flip versus the ones that must not.
Direction is a property of the document, not of individual components. The resolved locale arrives from negotiation; a single dir value on <html> cascades to every element below it, CSS logical properties read that value automatically, and the Unicode Bidi algorithm resolves mixed-script runs within it. Get this right once at the root and most of the layout flips for free; get it wrong — sprinkling dir per component or hard-coding left/right — and you ship a permanently broken mirror image to half the world’s writing systems.
Prerequisites
Concept & spec — direction, logical flow, and the bidi algorithm
Three specifications govern this layer. The Unicode Bidirectional Algorithm, defined in Unicode UAX #9, decides the visual order of characters when a string mixes left-to-right and right-to-left scripts — for example an Arabic sentence containing a Latin product name and a phone number. It assigns each character a direction, resolves embeddings and isolates, and reorders runs for display. You rarely implement it, but you constantly feed it correctly via the dir attribute and isolation primitives. The second spec is CSS Logical Properties and Values (a W3C specification), which replaces physical left/right/top/bottom with flow-relative inline-start/inline-end/block-start/block-end, so a single rule produces a mirrored layout when dir flips. The third is BCP 47 / RFC 5646, the tag format whose script subtag (and CLDR’s characterOrder data) tells you whether a locale is RTL at all.
This sits inside Core i18n Architecture & Locale Negotiation: negotiation produces the locale, and direction is a deterministic function of it. The critical mental model is that direction cascades. The dir attribute on <html> is inherited by every descendant unless explicitly overridden, and CSS logical properties read the computed direction of each element at layout time. So the correct architecture is to compute one dir value at the document root and let CSS and the bidi algorithm do the rest — never to thread direction through props or branch on isRTL in component bodies. Where you do need a local override — a single LTR code snippet inside an RTL paragraph — you scope it with dir on that element plus bidi isolation, not by re-architecting the page.
The pairing of formatted values and direction matters too: a number or date produced by the date & number formatting standards layer is still a left-to-right run that must be isolated when injected into RTL prose, or the surrounding punctuation jumps to the wrong side.
Step-by-step implementation
1. Resolve direction from the locale, once
Derive dir from the resolved locale with a single source of truth. Prefer the platform’s own data via Intl.Locale.prototype.getTextInfo() where available, with a CLDR-derived set as the fallback, so you never hard-code direction per component.
// dir.ts — the only place direction is computed
const RTL = new Set(['ar', 'he', 'fa', 'ur', 'ps', 'dv', 'yi']);
export function dirFor(locale: string): 'ltr' | 'rtl' {
try {
// Intl.Locale.getTextInfo() (or .textInfo) returns { direction: 'rtl' }
const info = (new Intl.Locale(locale) as any).getTextInfo?.();
if (info?.direction) return info.direction;
} catch { /* fall through */ }
return RTL.has(new Intl.Locale(locale).language) ? 'rtl' : 'ltr';
}
// dirFor('ar-EG') → 'rtl' dirFor('en-US') → 'ltr'
2. Set dir and lang on the document root
Emit both attributes server-side so the very first paint is correct and no client flash of LTR layout occurs. This is the one place direction is applied to the DOM.
<!-- SSR output for an Arabic request -->
<html lang="ar-EG" dir="rtl">
<head>…</head>
<body><!-- everything below inherits dir="rtl" --></body>
</html>
// Express/SSR: stamp the root element from the resolved locale
const dir = dirFor(resolvedLocale);
html = html.replace('<html', `<html lang="${resolvedLocale}" dir="${dir}"`);
3. Replace physical CSS with logical properties
Convert every left/right, margin-left, padding-right, text-align: left, and border-left into their flow-relative equivalents. One rule now serves both directions; nothing branches on locale.
/* before: breaks in RTL */
.card { margin-left: 1rem; padding-right: .5rem; text-align: left; border-left: 2px solid; }
/* after: mirrors automatically with dir */
.card {
margin-inline-start: 1rem;
padding-inline-end: .5rem;
text-align: start;
border-inline-start: 2px solid;
}
4. Isolate embedded opposite-direction runs
When you inject a user name, a Latin brand, or a formatted number into RTL prose, wrap it so the bidi algorithm treats it as one neutral unit. Use <bdi> (auto-isolation) for unknown-direction data and <bdo dir="…"> only to force an override.
<!-- bdi isolates so the username can't reorder surrounding text -->
<p>أهلاً <bdi>{{ userName }}</bdi>، لديك 3 رسائل</p>
<!-- a forced LTR code token inside RTL prose -->
<p>استخدم الأمر <span dir="ltr">npm run build</span> للبناء.</p>
5. Mirror directional icons (and only those)
Flip icons whose meaning is directional — back/forward arrows, list bullets, progress chevrons — and explicitly exclude icons that must never mirror: logos, clocks, media play buttons, checkmarks. A :dir() selector targets RTL without a body-level class. The full decision matrix lives in fixing mirrored icons and logical CSS properties.
/* flip only icons marked as directional */
:dir(rtl) .icon--directional { transform: scaleX(-1); }
/* hard opt-out for icons that must keep their orientation */
.icon--no-flip { transform: none !important; }
Configuration reference
| Option | Type | Description / default |
|---|---|---|
dir (HTML attr) |
'ltr' | 'rtl' | 'auto' |
Direction of an element and its descendants. Set on <html> once. 'auto' lets the browser infer from first strong character — use only for untrusted user data. |
direction (CSS) |
'ltr' | 'rtl' |
CSS equivalent of dir; prefer the HTML attribute at the root so SSR is correct without CSS. |
unicode-bidi (CSS) |
normal | embed | isolate | isolate-override | plaintext |
How an element participates in bidi. isolate mirrors <bdi> behavior in CSS. Default normal. |
writing-mode (CSS) |
horizontal-tb | vertical-rl | … |
Block flow axis. Keep horizontal-tb for Arabic/Hebrew; relevant for CJK vertical text, not RTL itself. |
text-align (CSS) |
start | end | left | right |
Use start/end so alignment follows direction; left/right are physical and won’t flip. |
postcss-logical |
build plugin | Transforms physical declarations to logical (or polyfills logical for old targets). Run in your PostCSS chain. |
rtlcss |
build tool | Generates a mirrored styles.rtl.css from an LTR source for codebases not yet on logical properties. |
‎ / ‏ |
HTML entity | Zero-width LEFT/RIGHT-TO-LEFT MARK; nudges a single neutral character’s resolved direction. Last resort after <bdi>. |
Framework variants
React / Next.js
Set dir and lang on the root element in the App Router layout from the resolved locale, never per component. Read direction through context (or the :dir() CSS selector) rather than branching on an isRTL prop in every component. For mixed-direction dynamic data, render a <bdi> element — in JSX that is simply <bdi>{userName}</bdi>.
// app/[locale]/layout.tsx
export default function RootLayout({ children, params }: Props) {
const dir = dirFor(params.locale);
return <html lang={params.locale} dir={dir}>{children}</html>;
}
Vue / Nuxt
Bind dir on the root in nuxt.config via the app.head.htmlAttrs (or useHead({ htmlAttrs: { dir } })) so SSR emits it. vue-i18n does not set direction for you — derive it from the active locale with the same dirFor helper and feed it to useHead. Use <bdi> directly in templates for interpolated user content.
Angular
Set dir on the document element from LOCALE_ID in an APP_INITIALIZER, or bind it on a wrapping element with [attr.dir]. Angular’s @angular/cdk/bidi exposes a Directionality service and Dir directive that emit a change stream, useful for components (like the CDK overlay) that must re-measure when direction flips. Prefer the root attribute; use Directionality only where a component genuinely needs to react.
Node.js backend / SSR
The server is where direction must be decided, because a flash of the wrong direction is visually jarring and impossible to hide after first paint. Resolve the tag, compute dir, and stamp <html lang dir> in the SSR response before it leaves the origin. Vary the edge cache on the locale signal so an RTL document is never served to an LTR visitor from a shared cache entry — the same Vary discipline the fallback-chain layer uses.
Verification
Assert direction resolution in unit tests and catch physical-property regressions with a lint rule, then prove the rendered root carries the attribute. The first test pins the locale→dir mapping; the lint gate fails any new margin-left/padding-right in component CSS.
import { test, expect } from 'vitest';
import { dirFor } from './dir';
test('RTL locales resolve to rtl, others to ltr', () => {
for (const l of ['ar-EG', 'he-IL', 'fa-IR', 'ur-PK']) expect(dirFor(l)).toBe('rtl');
for (const l of ['en-US', 'de-DE', 'ja-JP']) expect(dirFor(l)).toBe('ltr');
});
# CI gate: forbid physical inline properties in component styles
grep -rEn '(margin|padding|border)-(left|right)\s*:' src/ \
&& { echo "Use logical properties (inline-start/inline-end)"; exit 1; } || true
# Smoke-test the SSR root carries dir="rtl" for an Arabic request
curl -s -H 'Accept-Language: ar' http://localhost:3000/ | grep -q '<html[^>]*dir="rtl"'
Common pitfalls
- Setting
dirper component instead of at the root. Direction is inherited; scattering it causes islands that don’t agree. Set it once on<html>and override only for genuine local exceptions. - Physical CSS that never flips.
margin-left,text-align: left, andleft: 0stay put in RTL. Convert tomargin-inline-start,text-align: start,inset-inline-start. Automate stragglers withpostcss-logicalorrtlcss, and see fixing mirrored icons and logical CSS properties. - Scrambled mixed-direction strings. Injecting a phone number or Latin name into Arabic prose without isolation lets bidi reorder the punctuation. Wrap unknown-direction data in
<bdi>; force a known token withdir="ltr"; nudge a single character with‎/‏only as a last resort. - Mirroring icons that must not flip. Clocks, logos, checkmarks, and media play buttons keep their orientation; only directional arrows and chevrons flip. Mark icons explicitly rather than blanket-flipping with
scaleX(-1). - Unisolated formatted numbers. A value from the date & number formatting standards layer is an LTR run; embed it through an isolated placeholder or its sign and grouping land on the wrong side.
- Forgetting
langalongsidedir. Direction without the language tag breaks font selection, hyphenation, and screen-reader pronunciation. Always emit both.
FAQ
Where should I set the dir attribute for RTL languages?
Set it once on the <html> element from the resolved locale, server-side, so the first paint is correct. Direction is inherited by every descendant, so a single root dir="rtl" flips the whole document. Override dir on an inner element only for a genuine local exception, such as an LTR code snippet inside RTL prose — never set direction per component or branch on an isRTL flag throughout the tree.
What are CSS logical properties and why do they matter for RTL?
Logical properties (margin-inline-start, padding-inline-end, inset-inline-start, text-align: start) are flow-relative: they resolve to physical sides based on the element’s computed direction. One rule written with logical properties produces a correct LTR layout and a correctly mirrored RTL layout with no locale branching, which is why physical left/right rules are the single largest source of broken RTL layouts.
When do I need , , or the / marks?
Use <bdi> to isolate dynamic data of unknown direction (user names, search terms, formatted numbers) so the Unicode Bidi algorithm can’t reorder the surrounding text. Use <bdo dir="…"> to force a specific direction override. Use the zero-width ‎/‏ marks only as a last resort to nudge a single neutral character (like a trailing parenthesis) when wrapping is impractical.
Which icons should mirror in RTL and which should not?
Mirror icons whose meaning is directional: back/forward and next/previous arrows, breadcrumb chevrons, progress indicators, and undo/redo. Do not mirror icons whose form is fixed: logos, clocks and watches, media play buttons, checkmarks, search magnifiers, and most object icons. Mark each icon’s intent explicitly and flip only the directional set with a :dir(rtl) selector plus scaleX(-1).
Do I have to rewrite all my CSS to support RTL, or can I automate it?
You can automate the bulk. postcss-logical transforms physical declarations into logical ones (or polyfills logical for older targets) in your build chain, and rtlcss generates a mirrored stylesheet from an existing LTR source. Both buy you time, but the durable fix is authoring in logical properties directly so a single stylesheet serves both directions with no second build artifact.
Related
- Date & Number Formatting Standards — the LTR formatted values that must be bidi-isolated when injected into RTL prose.
- Fixing mirrored icons and logical CSS properties — the full mirror/no-mirror icon matrix and the physical-to-logical conversion in depth.
- Locale Negotiation Strategies — where the resolved locale that decides direction comes from.
- Fallback Chain Configuration — how a usable locale is guaranteed before direction is computed.
- ICU Message Format Deep Dive — injecting isolated values into translated sentences without breaking bidi order.