Table of Contents
Table of Contents
- Right-to-Left (RTL) Support: A Practical CSS and React Implementation Guide
- Which Languages Need RTL?
- CSS Logical Properties: The Foundation
- The dir Attribute
- Flexbox and Grid in RTL
- Common Patterns That Need RTL Fixes
- Navigation Arrows and Chevrons
- Breadcrumbs
- Progress Bars
- Box Shadows
- React Implementation
- Tailwind CSS RTL Support
- Testing RTL Layouts
- Chrome DevTools
- Playwright
- Visual Regression
- Manual QA Checklist
- Fonts and Typography for RTL
- Bidirectional Text (Bidi)
- RTL and Better i18n
- RTL Implementation Checklist
Right-to-Left (RTL) Support: A Practical CSS and React Implementation Guide
RTL support is one of those features that gets deferred until it absolutely can't be anymore. Then teams discover they've built an entire product that assumes text flows left-to-right, and retrofitting RTL means touching every layout file they own.
This guide is about doing it right from the start — or at least, doing the retrofit as cleanly as possible. We'll cover CSS logical properties, the dir attribute, flexbox behavior, React patterns, Tailwind utilities, and everything in between.
Which Languages Need RTL?
Before the implementation, understand the audience. RTL writing systems include:
- Arabic — 400M+ native speakers, official in 26 countries
- Hebrew — 10M+ speakers, dominant in a high-GDP market
- Persian/Farsi — 80M+ speakers across Iran, Afghanistan, Tajikistan
- Urdu — 70M+ native speakers, co-official in Pakistan
- Pashto — 60M+ speakers
- Sindhi, Uyghur, Kurdish (Sorani) — smaller but significant user bases
Arabic and Hebrew markets alone represent enormous e-commerce and SaaS revenue potential. Products without RTL support simply don't ship in these regions — localizing content without localizing layout is meaningless.
CSS Logical Properties: The Foundation
The single biggest improvement you can make to your CSS for RTL support is switching from physical properties to logical properties. Physical properties (margin-left, padding-right, border-left) are hardcoded to screen edges. Logical properties (margin-inline-start, padding-inline-end, border-inline-start) adapt automatically based on the document's writing direction.
This one change handles roughly 80% of RTL layout issues.
Mapping table:
| Physical Property | Logical Equivalent |
|---|---|
margin-left | margin-inline-start |
margin-right | margin-inline-end |
padding-left | padding-inline-start |
padding-right | padding-inline-end |
border-left | border-inline-start |
border-right | border-inline-end |
left | inset-inline-start |
right | inset-inline-end |
margin-top | margin-block-start |
margin-bottom | margin-block-end |
padding-top | padding-block-start |
padding-bottom | padding-block-end |
text-align: left | text-align: start |
text-align: right | text-align: end |
width | inline-size |
height | block-size |
Browser support in 2026: Excellent. All major browsers have supported CSS logical properties for years. You can use them without hesitation.
/* Before — breaks in RTL */
.card {
margin-left: 1rem;
padding-right: 1.5rem;
border-left: 2px solid var(--accent);
text-align: left;
}
/* After — works in both LTR and RTL */
.card {
margin-inline-start: 1rem;
padding-inline-end: 1.5rem;
border-inline-start: 2px solid var(--accent);
text-align: start;
}
In LTR mode, margin-inline-start resolves to margin-left. In RTL, it resolves to margin-right. You write the CSS once, both directions work correctly.
The dir Attribute
Set direction at the HTML element level:
<html lang="ar" dir="rtl">
Or per-element when you have mixed content:
<p dir="rtl">مرحباً بالعالم</p>
<p dir="ltr">Hello world</p>
CSS direction property does the same thing but in CSS:
.arabic-content {
direction: rtl;
}
The [dir="rtl"] selector pattern is useful for RTL-specific overrides when logical properties aren't enough:
/* Default (LTR) */
.nav-icon {
transform: none;
}
/* RTL override for directional icons */
[dir="rtl"] .nav-icon--arrow {
transform: scaleX(-1);
}
unicode-bidi: bidi-override forces a specific direction regardless of content. Use this sparingly — usually only needed for code blocks or content that must not reorder:
.code-block {
direction: ltr;
unicode-bidi: isolate;
}
Flexbox and Grid in RTL
Here's genuinely good news: flexbox and CSS grid respect the document direction automatically.
.nav {
display: flex;
flex-direction: row;
gap: 1rem;
}
In LTR, items flow left-to-right. In RTL, the same CSS causes items to flow right-to-left. You don't need to change anything.
What works automatically:
flex-direction: rowreverses in RTLjustify-content: flex-startaligns to the inline start (right in RTL)- Grid column ordering respects direction
- Grid template areas adapt correctly
What needs manual attention:
/* This transform won't auto-flip */
.slide-in {
transform: translateX(-100%);
}
/* Fix with logical property equivalent */
[dir="rtl"] .slide-in {
transform: translateX(100%);
}
Positioning with left/right also won't flip:
/* Breaks in RTL */
.tooltip {
position: absolute;
left: 100%;
}
/* Use logical properties instead */
.tooltip {
position: absolute;
inset-inline-start: 100%;
}
Common Patterns That Need RTL Fixes
Navigation Arrows and Chevrons
Arrow icons indicating direction must flip in RTL:
/* Flip directional icons only */
[dir="rtl"] .icon--arrow-right,
[dir="rtl"] .icon--chevron-right,
[dir="rtl"] .icon--next,
[dir="rtl"] .icon--forward {
transform: scaleX(-1);
}
Do NOT flip: checkmarks, warning triangles, logos, user avatars, upload/download icons, media controls (play/pause). These are not directional.
Breadcrumbs
The separator between breadcrumb items must reverse:
.breadcrumb-separator::before {
content: "/";
}
[dir="rtl"] .breadcrumb-separator::before {
content: "\\";
/* Or use a proper RTL-neutral separator like • */
}
Better: use a neutral separator character or an SVG that you flip.
Progress Bars
Fill direction matters visually:
.progress-fill {
width: var(--progress);
/* In LTR: fills from left */
/* In RTL: should fill from right */
transform-origin: inline-start;
}
[dir="rtl"] .progress-fill {
margin-inline-start: auto;
/* Or: use a mirrored gradient */
}
Box Shadows
Shadows indicating depth or elevation often look directional:
/* LTR — shadow on the right */
.card {
box-shadow: 4px 0 8px rgba(0,0,0,0.1);
}
/* RTL — shadow should be on the left */
[dir="rtl"] .card {
box-shadow: -4px 0 8px rgba(0,0,0,0.1);
}
React Implementation
Structure direction state as a context so any component can consume it:
// direction-context.tsx
import { createContext, useContext, ReactNode } from "react";
type Direction = "ltr" | "rtl";
const DirectionContext = createContext<Direction>("ltr");
interface DirectionProviderProps {
direction: Direction;
children: ReactNode;
}
export function DirectionProvider({ direction, children }: DirectionProviderProps) {
return (
<DirectionContext.Provider value={direction}>
<div dir={direction} className={`dir-${direction}`}>
{children}
</div>
</DirectionContext.Provider>
);
}
export function useDirection(): Direction {
return useContext(DirectionContext);
}
At the app root, pass direction from your locale configuration:
// app.tsx
import { DirectionProvider } from "./direction-context";
const RTL_LOCALES = new Set(["ar", "he", "fa", "ur"]);
export function App({ locale }: { locale: string }) {
const direction = RTL_LOCALES.has(locale) ? "rtl" : "ltr";
return (
<DirectionProvider direction={direction}>
<Router />
</DirectionProvider>
);
}
Components that need to know direction consume the hook:
function NavArrow() {
const direction = useDirection();
return (
<svg
style={{
transform: direction === "rtl" ? "scaleX(-1)" : "none"
}}
aria-hidden="true"
>
{/* arrow path */}
</svg>
);
}
With CSS Modules: add a data attribute and select on it:
<div data-direction={direction} className={styles.container}>
/* component.module.css */
.container {
padding-inline-start: 1rem;
}
/* Override only what logical properties can't handle */
.container[data-direction="rtl"] .icon {
transform: scaleX(-1);
}
When using a platform like Better i18n, locale direction is available from the SDK alongside the locale itself, so you can derive dir at the same point you derive the locale without any additional setup.
Tailwind CSS RTL Support
Tailwind includes rtl: and ltr: variants out of the box. Enable them in your config:
// tailwind.config.js
module.exports = {
// ...
future: {
hoverOnlyWhenSupported: true,
},
};
The variants activate when an ancestor element has dir="rtl" or dir="ltr".
Basic usage:
<!-- Margin flips based on direction -->
<div class="ms-4 me-2">
<!-- ms- = margin-inline-start, me- = margin-inline-end -->
<!-- These are Tailwind's logical property utilities -->
</div>
<!-- Explicit directional overrides -->
<button class="ltr:ml-4 rtl:mr-4">Submit</button>
<!-- Icon flipping -->
<svg class="rtl:scale-x-[-1]">...</svg>
Tailwind's logical property utilities (ms-, me-, ps-, pe-, border-s, border-e, start-, end-) are the correct default choice — they replace physical utilities and handle RTL without variants at all:
<!-- Before: physical utilities, breaks in RTL -->
<nav class="pl-6 border-l-2 text-left">
<!-- After: logical utilities, works in both -->
<nav class="ps-6 border-s-2 text-start">
Use rtl: and ltr: variants only for things logical properties can't handle — like icon transforms or animation origins.
Testing RTL Layouts
Chrome DevTools
The quickest way to preview RTL: open DevTools, go to Rendering tab, and find "Emulate CSS media feature forced-colors" — but more useful is simply adding dir="rtl" to the root element via the Elements panel. No page reload needed.
Playwright
Write direction-aware tests that validate layout in both modes:
// rtl.spec.ts
import { test, expect } from "@playwright/test";
test.describe("RTL layout", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
await page.evaluate(() => {
document.documentElement.setAttribute("dir", "rtl");
});
});
test("navigation renders correctly in RTL", async ({ page }) => {
const nav = page.locator("nav");
await expect(nav).toBeVisible();
// Verify the menu is on the right side in RTL
const navBox = await nav.boundingBox();
const viewportWidth = page.viewportSize()?.width ?? 1280;
expect(navBox!.x).toBeGreaterThan(viewportWidth / 2);
});
test("form inputs align correctly", async ({ page }) => {
const label = page.locator('label[for="email"]');
const input = page.locator("#email");
const labelBox = await label.boundingBox();
const inputBox = await input.boundingBox();
// In RTL, label should be to the right of or above the input
// This depends on your layout, adjust assertion accordingly
expect(labelBox).toBeTruthy();
expect(inputBox).toBeTruthy();
});
});
Visual Regression
Run visual regression tests with a dir="rtl" variant. With Playwright's built-in screenshot comparison:
test("RTL homepage snapshot", async ({ page }) => {
await page.goto("/");
await page.evaluate(() => {
document.documentElement.setAttribute("dir", "rtl");
});
await expect(page).toHaveScreenshot("homepage-rtl.png");
});
Manual QA Checklist
- Navigation items flow right-to-left
- Sidebar is on the right
- Form labels align correctly
- Directional icons (arrows, chevrons) are flipped
- Progress bars fill from right
- Dropdowns open in the correct direction
- Modals/drawers open from the correct side
- Breadcrumb separators point the right way
- Text alignment is correct throughout
- Scrollbars appear on the left (browsers handle this automatically)
- Numbers and Latin text remain LTR within RTL content
Fonts and Typography for RTL
Arabic and Hebrew need appropriate fonts — default system fonts may render poorly.
Recommended fonts:
- Noto Sans Arabic — Google's comprehensive coverage, free, matches Noto family
- IBM Plex Arabic — Excellent for technical/developer tools, pairs with IBM Plex Sans
- Cairo — Modern, clean, good for UI
- Tajawal — Geometric, pairs well with sans-serif LTR fonts
@import url("https://fonts.googleapis.com/css2?family=Noto+Sans+Arabic:wght@400;500;600;700&display=swap");
:lang(ar) {
font-family: "Noto Sans Arabic", "Segoe UI", system-ui, sans-serif;
font-size: 1.05em; /* Arabic often reads better slightly larger */
line-height: 1.8; /* Arabic needs more vertical space than Latin */
}
:lang(he) {
font-family: "Noto Sans Hebrew", "Segoe UI", system-ui, sans-serif;
line-height: 1.6;
}
Key typographic adjustments:
- Arabic text typically benefits from 1-2px larger font size compared to equivalent Latin text at the same size
- Line height should be 1.6–2.0 for Arabic (vs 1.4–1.6 for Latin) due to diacritical marks
- Letter-spacing (
letter-spacing) should generally be 0 or slightly negative for Arabic — never positive font-variant-numeric: ltrkeeps numerals rendering left-to-right within RTL text
Bidirectional Text (Bidi)
RTL documents often contain LTR fragments: English brand names, numbers, URLs, code. The Unicode Bidirectional Algorithm handles most cases automatically, but complex layouts need explicit control.
The <bdi> element isolates a fragment's directionality from surrounding text:
<p dir="rtl">
المستخدم <bdi>JohnDoe123</bdi> أرسل رسالة
</p>
Without <bdi>, the username might render with incorrect directionality depending on its characters.
unicode-bidi: isolate does the same in CSS:
.username {
unicode-bidi: isolate;
}
Numbers stay LTR in RTL text automatically — the Bidi algorithm handles this. Phone numbers, prices, dates all render in reading order. You don't usually need to intervene.
Code blocks must always be LTR:
pre, code {
direction: ltr;
unicode-bidi: isolate;
text-align: start; /* start = left in LTR */
}
Brand names and product names that are written in Latin script will naturally remain LTR within RTL text. If you're seeing incorrect rendering, wrap them in <bdi> or a span with dir="ltr":
<span dir="ltr">Better i18n</span>
When you're managing translations through a platform like Better i18n, the SDK delivers locale strings already including any inline direction markers your translators have added, so you don't need to post-process strings in application code.
RTL and Better i18n
If you're managing translations across LTR and RTL locales, the direction data should live alongside the locale configuration — not scattered across components. Better i18n provides locale metadata including text direction through the same SDK that delivers your translation strings. When you switch a user to Arabic, direction flips automatically as part of locale activation rather than as a separate step someone has to remember to wire up.
RTL Implementation Checklist
Before shipping RTL support, verify:
CSS
- Switched physical margin/padding/border to logical properties
- Replaced
left/rightpositioning withinset-inline-start/inset-inline-end - Replaced
text-align: left/rightwithtext-align: start/end - Directional icons flip with
[dir="rtl"]selector orrtl:Tailwind variant - Animations and transforms account for direction
HTML
-
dirattribute set on<html>element -
langattribute set correctly -
<bdi>used for mixed-direction inline content
React
- Direction context provider wraps the app
- Locale-to-direction mapping handles all RTL locales
- Components consuming direction use the hook, not hardcoded values
Testing
- RTL screenshots reviewed for all key pages
- Automated tests cover RTL layout
- QA checklist completed
Typography
- RTL-appropriate fonts loaded for Arabic/Hebrew/Persian
- Line height adjusted for RTL scripts
- Font size tweaked where needed
RTL support isn't a single feature — it's a property of the entire layout system. The teams that get it right treat direction as a first-class dimension of their design system, choosing logical properties from the start and letting flexbox and grid do the heavy lifting. The teams that struggle are the ones who added LTR assumptions into every corner of their CSS and now need to override them one by one.
Start with logical properties. Set direction on the HTML element. Let flexbox handle the rest automatically. Then address the specific patterns — icons, transforms, fonts — that need directional awareness. Done carefully, RTL support is far less work than most teams expect.
Better i18n is a developer-first localization platform built for modern frontend teams. Type-safe SDKs, Git-based workflows, CDN delivery, and AI translation with glossary enforcement — without locale files in your repo.