Right-to-left layouts
How to build layouts that mirror correctly for Arabic, Hebrew, Persian, and Urdu readers: the dir attribute, CSS logical properties, which icons to flip, text that mixes both directions, and how to test all of it.
The problem: a launch that looks broken
Picture a shopping app that has done its translation work well: every string is in a catalog, and dates and prices go through Intl. The team flips the locale to ar for the launch in Riyadh and Cairo. Every word is correct, and the page still looks broken.
The text runs from the right, because the team set dir="rtl". But the product card keeps its accent border on the left. The discount badge, pinned to the top-right corner, now covers the product name.
The back arrow points left, which in Arabic means forward. The carousel swipes the wrong way. And the phone number +966 55 123 4567 shows up scrambled.
None of these are translation bugs. They come from one hidden assumption spread through thousands of lines of CSS, icons, and JavaScript: that content flows from left to right. This lesson is about removing that assumption, so the same code serves both directions.
dir="rtl". Every bug on the left comes from code that assumed left to right: a physical border, a badge pinned with right, an icon, and an unisolated number.Who reads right to left
Arabic, Hebrew, Persian, and Urdu are the big right-to-left (RTL) languages, plus smaller ones such as Pashto, Kurdish (Sorani), and Yiddish. Arabic alone is official in more than 20 countries. If your product sells in the Gulf, North Africa, Israel, Iran, or Pakistan, RTL is the main layout for those users, not an edge case.
Five kinds of RTL bugs
- Layout that doesn't flip. Physical CSS such as
margin-left,left: 0,float: right, andtext-align: leftstays on the same side when the text direction changes. - Icons that point the wrong way. A back arrow, a chevron in a list row, or a send icon keeps its left-to-right meaning.
- Scrambled mixed text. An English brand name, a number, or a phone number inside Arabic text lands in the wrong place, or drags the punctuation next to it to the wrong side.
- Interaction in the wrong direction. Carousels, sliders, swipe gestures, and progress bars move against the reading direction.
- Things that should NOT flip, flipped. A play button, a clock, a checkmark, or a logo mirrored by an overeager global rule.
The last kind is the reason RTL is a design topic and not only a CSS trick. Flipping everything with transform: scaleX(-1) on the body would make the bugs worse. You need to know what direction means for each part of the page.
Think first
Your codebase has 4,000 uses of margin-left, padding-right, and left:. Why is a second stylesheet that overrides each of them for RTL a poor long-term plan?
Every new rule needs a matching override that nobody remembers to write, so RTL bugs come back with every feature.
Writing direction-neutral CSS once, with logical properties, removes the whole class of bugs instead of patching each one.
The mental model: start and end, not left and right
The one idea to keep in your head: a page has two axes, and CSS can name them by their meaning instead of by the screen. The inline axis is the direction a line of text runs. The block axis is the direction lines stack, one paragraph under another.
Each axis has a start and an end. In English, inline-start is the left edge and inline-end is the right edge. In Arabic, inline-start is the right edge.
The block axis runs from top to bottom in both, so block-start is the top for both languages. Only the inline axis flips.
margin-inline-start follows the first; margin-left follows neither.Mirrored, not reversed
An RTL layout is a mirror image of the LTR one along a vertical line down the middle of the screen. The navigation drawer opens from the right, and the logo sits at the top right.
The first column of a table is on the right. The close button of a dialog, which sits at the top-right in English, moves to the top-left, because it lives at the inline-end.
Mirroring is about layout and flow, not about reversing every picture. Think of a country that drives on the other side of the road: the lanes and roundabouts are mirrored, but the numbers on speed signs read the same and the dashboard clock still turns clockwise.
Three questions for every element
- Is its position about reading order? Then it should follow the inline direction. Sidebars, labels, badges, indents, and list bullets all are. Use start and end.
- Does it point along the reading flow? Then the icon should mirror. Back, forward, next, previous, and the chevron that says "there is more this way" all do.
- Does it show something real that has its own direction? Then leave it alone. Clocks, media controls, a checkmark, a logo, a map, a graph of numbers.
Check your understanding · Exercise 1
You switch a page from dir="ltr" to dir="rtl". Which of these should end up on the opposite side or point the opposite way? Select all that apply.
Declaring direction: dir, dir="auto", and bdi
Everything starts with one HTML attribute. dir sets the base direction of an element and everything inside it, because it is inherited. Put it on the root element together with lang, and the browser does a surprising amount of work for you.
// Root layout: the server renders the direction for the locale
const RTL_SCRIPTS = new Set(["Arab", "Hebr", "Thaa", "Syrc", "Nkoo", "Adlm", "Rohg"]);
function directionFor(locale: string): "ltr" | "rtl" {
// maximize() fills in the likely script: "fa" becomes "fa-Arab-IR"
const { script } = new Intl.Locale(locale).maximize();
return script && RTL_SCRIPTS.has(script) ? "rtl" : "ltr";
}
export function RootLayout({ locale, children }: Props) {
return (
<html lang={locale} dir={directionFor(locale)}>
<body>{children}</body>
</html>
);
}
ar-MA is RTL, en-IL is LTR, and az-Arab (Azerbaijani in Arabic script) is RTL while plain az is LTR.What dir="rtl" does by itself
- Text aligns to the right, and the first character of a line sits at the right edge.
- Table columns run from right to left, so the first
tdis the rightmost cell. - Flex rows and grid columns start at the right, because
flex-startand grid line 1 follow the inline direction. - The
:dir(rtl)selector starts matching, so you can target the few things that need special care.
Set the direction in the HTML the server sends, not in a script after load. If JavaScript sets document.documentElement.dir after the first paint, users see the page jump from left to right. With streaming or static pages, the attribute must be part of the cached HTML for that locale.
Use the attribute, not the CSS direction property
CSS has a direction: rtl property, and it changes the layout too. The CSS specification itself advises authors not to use it for documents. Direction is part of the meaning of the content, like lang: it must survive when the CSS fails to load, when the text is copied, read in a feed reader, or shown in reader mode. The :dir() selector also reads the attribute and ignores the CSS property.
dir="auto" for text you don't control
Your page has one direction, but the content inside it may not. On an English site, a user from Tel Aviv writes a review in Hebrew. On an Arabic site, someone searches for iPhone 16. You can't know the direction of user content ahead of time, so let the browser look: dir="auto" scans the text for the first character with a strong direction (a Latin, Hebrew, or Arabic letter, say) and uses that direction for this element only.
Put dir="auto" on every element that shows user-generated text: comments, chat messages, display names, post titles, and search inputs. The rule is a guess, and it guesses wrong when a Hebrew message starts with an English word, but it beats forcing the page direction on every message.
bdi for a value inside a sentence
When the user text is a fragment inside a sentence, such as a name followed by a count, wrap it in bdi (bidirectional isolate). It works like dir="auto" and also fences the value off, so its direction can't spill into the words around it. Chapter 6 explains why that fence matters. Any element with a dir attribute is isolated the same way, so span dir="auto" works too.
dir. Values inside sentences also need isolation, which bdi and any element with a dir attribute give you.| Where | What to write | Why |
|---|---|---|
| Root of the page | html lang="ar" dir="rtl" | Sets the base direction for everything; render it on the server |
| A block of user content | p dir="auto" | Picks the direction of that text from its first strong letter |
| A value inside a sentence | bdi around the value | Auto direction plus isolation from the surrounding words |
| Content known to be LTR on an RTL page | dir="ltr" (code, URLs, emails) | Keeps a code sample or an email address in its own order |
| Inputs for user text | input dir="auto" dirname="q.dir" | The caret follows what is typed; the form submits the direction too |
Check your understanding · Exercise 2
An English news site shows reader comments, some in Arabic. Which markup choices are right? Select all that apply.
CSS logical properties: write it once for both directions
Physical CSS names the sides of the screen: left, right, top, bottom. Logical properties name the sides of the content: inline-start, inline-end, block-start, block-end. When the direction flips, a logical property moves with it. margin-inline-start: 16px is a left margin in English and a right margin in Arabic, from the same line of CSS.
| Physical | Logical | In an RTL page it means |
|---|---|---|
margin-left: 16px | margin-inline-start: 16px | 16px on the right |
padding-right: 8px | padding-inline-end: 8px | 8px on the left |
padding-left: 8px; padding-right: 24px | padding-inline: 8px 24px | 8px on the right, 24px on the left (start first, then end) |
left: 0 | inset-inline-start: 0 | Pinned to the right edge |
border-left: 4px solid | border-inline-start: 4px solid | Accent bar on the right |
border-top-left-radius | border-start-start-radius | The top-right corner is rounded |
text-align: left | text-align: start | Text aligned right |
float: left | float: inline-start | Floats to the right |
width, height | inline-size, block-size | Unchanged in horizontal text; they swap only in vertical writing |
All of these work in every current browser. The margin, padding, border, inset, and size properties have been Baseline (supported across Chrome, Edge, Firefox, and Safari) for years. The last to arrive was float: inline-start, in late 2023. You don't need a polyfill or a build step for any of them in 2026.
Lab: one card, every combination
The lab below draws one product card. Flip the page direction, switch the CSS between physical and logical, and choose which icons mirror in RTL: none, only the directional chevron, or every icon including the play button. Watch which combinations break, and notice that one bug (the chevron) survives even when the CSS is fully logical.
border-left: 6px solid; text-align: left; .badge { right: 12px }
dir; the border, badge, and text alignment move only when written with logical properties. Icons are a separate decision.Bugs: accent border stuck on the left (border-left); discount badge covers the image (right: 12px); text pushed away from the image (text-align: left); the chevron points back toward the start.
Flexbox and grid already follow the direction
Flexbox and grid were designed after logical thinking took hold, so they speak start and end natively. In a row, justify-content: flex-start packs items at the inline-start, the first item sits at the start edge, and gap has no side at all. Grid column line 1 is at the inline-start, and grid-template-areas drawn as "sidebar main" put the sidebar on the right in RTL with no extra code.
The common mistake is to fight this: adding flex-direction: row-reverse for Arabic. The browser already reversed the visual order, so now it is reversed twice and back to LTR. Delete the hack.
.card {
display: grid;
grid-template-columns: 96px 1fr; /* image at start, text at end */
gap: 16px;
padding-inline: 16px 24px; /* start, then end */
border-inline-start: 4px solid var(--accent);
border-start-end-radius: 12px;
text-align: start;
}
.card .badge {
position: absolute;
inset-block-start: 12px;
inset-inline-end: 12px; /* right in LTR, left in RTL */
}
[dir] selector.dir. Transforms, shadows, four-value shorthands, and background positions don't, so they need :dir(rtl) or a sign flip.What logical properties don't cover
Some CSS has no logical version. This is where RTL bugs hide in a codebase that has otherwise switched over:
- Transforms.
translateX(100%)moves right in both directions. Slide-in drawers, toasts, and carousel tracks need the sign flipped for RTL. - Shadows, gradients, and backgrounds. The x offset of
box-shadow,linear-gradient(to right, ...), andbackground-position: leftare physical. - Four-value shorthands.
margin: 0 8px 0 16pxandinset: 0 auto auto 0are physical, top, right, bottom, left. Only the-inlineand-blockshorthands are logical. - JavaScript geometry.
getBoundingClientRect().left,clientX, and drag math are physical pixels. - SVG and canvas. Their coordinates never flip.
For those cases, use the :dir(rtl) pseudo-class in CSS, or read the computed direction in JavaScript with getComputedStyle(el).direction. Prefer :dir(rtl) to the attribute selector [dir="rtl"]: the attribute selector only matches the element that has the attribute, while :dir() matches every element whose direction is RTL, including through inheritance and dir="auto". :dir() has worked in all major browsers since late 2023 (Baseline 2023).
.drawer {
inset-inline-start: 0;
transform: translateX(-100%); /* hidden off the left edge */
}
.drawer:dir(rtl) {
transform: translateX(100%); /* hidden off the right edge */
}
.drawer[data-open] {
transform: none;
}
Decision How should a product make its CSS work in both directions?
What we pick: Use logical properties for all new code and enforce them with a linter. Use a build-time flipper only as a bridge for a large legacy codebase while it migrates, and keep hand-written overrides for the handful of transforms and shadows that have no logical form.
Check your understanding · Exercise 3
A page switches to dir="rtl". Which of these rules move to the other side with no extra work? Select all that apply.
Mirroring icons: what to flip and what to leave
CSS can flip the layout, but it can't decide what a picture means. An icon about the reading flow mirrors; an icon about something real stays as it is. Getting it wrong either way is visible: a back arrow that points forward, or a clock that runs backward.
Mirror icons that point along the reading flow
- Navigation arrows. Back, forward, next, previous. In RTL, back points right, because the previous page is at the start, which is on the right.
- Chevrons. The chevron at the end of a list row that opens a detail page, breadcrumb separators, and the expand arrow of a side menu.
- Text and list icons. Indent, outdent, align to start, bulleted list, numbered list, and text direction toggles.
- Progress along a line. Progress bars, sliders, steppers, and a "step 2 of 4" track fill from the inline-start, so from the right in RTL.
- Icons that show movement through content. Reply and forward in an email client, a send arrow, and a "skip to next" that means the next item (not the next track of audio).
Leave icons that show something real
- Media controls. Play, pause, fast-forward, rewind, and the playback progress bar of a video. They describe the direction a tape or timeline plays, which is the same for every culture, so RTL video players keep them as they are.
- Clocks and circular arrows. Clocks turn clockwise everywhere, so timers, history, refresh, and rotate icons stay as drawn.
- Checkmarks and status marks. A checkmark, a cross, a warning triangle, or a plus has no reading direction.
- Logos and brand marks. A logo is a fixed picture. Mirroring it is a brand violation.
- Real objects. A camera, a headphone, a keyboard, a guitar, a hand holding a phone. The right-handed mouse icon stays right-handed.
- Anything that contains text or numbers. Mirroring would turn the digits backward. If it needs an RTL version, draw a separate asset.
Decide once, in the design system
If every feature team decides about every icon, the answers will disagree. Put the decision in the icon library: each icon carries a mirrorInRtl flag, set once by a designer, and the icon component applies it. Apple's SF Symbols ships right-to-left variants of its directional symbols, and Material Design's bidirectionality guide shows which icons mirror, which makes a good starting list.
const ICONS = {
"arrow-back": { path: ArrowBack, mirrorInRtl: true },
"chevron-end": { path: ChevronRight, mirrorInRtl: true },
"play": { path: Play, mirrorInRtl: false },
"refresh": { path: Refresh, mirrorInRtl: false },
} as const;
export function Icon({ name }: { name: keyof typeof ICONS }) {
const { path: Svg, mirrorInRtl } = ICONS[name];
return <Svg className="icon" data-mirror={mirrorInRtl || undefined} aria-hidden />;
}
/* icon.css */
/* .icon[data-mirror]:dir(rtl) { transform: scaleX(-1); } */
chevron-end), not by drawing (chevron-right).Name icons by what they mean, such as arrow-back and chevron-end, rather than by how they look, such as arrow-left. A component that asks for arrow-left to mean "back" is wrong in RTL whether or not the icon flips, because the name and the meaning no longer match.
Check your understanding · Exercise 4
You're building the Arabic version of a podcast app. Which icons should mirror? Select all that apply.
Bidirectional text and the Unicode Bidi Algorithm
Real text mixes directions all the time. An Arabic product page mentions iPhone 16. A Hebrew chat message includes a URL. An English feed shows an Arabic user name.
Every phone number, price, and date inside RTL text is written left to right. Text that contains both directions is called bidirectional, or bidi for short.
The browser lays it out with the Unicode Bidirectional Algorithm (UBA, Unicode Standard Annex 9). You don't call it; it runs on every line of text. Knowing roughly how it works is what lets you fix the bugs it causes, because its bugs look random until you see the rules.
Logical order in memory, visual order on screen
Text is stored in logical order: the order a person reads and types it. For an Arabic sentence, the first letter in the string is the rightmost on screen. The algorithm turns logical order into visual order only when drawing, so never reverse strings yourself.
In the diagrams of this lesson, CAPITAL letters stand for Arabic or Hebrew letters and lowercase letters for English. So the logical string BUY iphone NOW is an Arabic sentence with an English word in the middle.
How the algorithm decides, in four steps
- Find the base direction of the paragraph. It comes from
diron the element (or from the first strong letter withdir="auto"). The base gets an embedding level: 0 for LTR, 1 for RTL. Even levels are LTR, odd levels are RTL. - Classify every character. Letters are strong: Latin is L, Hebrew is R, and Arabic is AL, an RTL type that also turns the digits after it into Arabic numbers. Digits are weak: they lean one way but can be pulled. Spaces and most punctuation are neutral.
- Resolve the weak and neutral ones. Digits after a Latin letter become LTR. A neutral between two characters of the same direction takes that direction; between two different directions, it takes the paragraph's base direction. Nearly every bidi bug comes from this step.
- Reorder for display. Each character now has a level. Runs at odd levels are reversed, from the highest level down, and the line is drawn.
16 follows a Latin letter, so it joins the LTR run, and the space between iphone and 16 sits between two LTR pieces, so it does too.Numbers: always left to right, but grouped by their neighbours
Digits in RTL text are read left to right, so 2026 stays 2026 and 1,250.00 keeps its order. The algorithm gives a number a higher, even level inside RTL text, which keeps its digits in order. That part just works.
The trouble is the characters between numbers. Take the phone number +966 55 123 4567 inside an RTL paragraph. Each digit group stays LTR, but the spaces between groups are neutrals sitting between two numbers. Numbers count as RTL when deciding neutrals, so the spaces become RTL.
As a result, the groups are laid out right to left: the user sees 4567 123 55 966+. Every group is correct and the number is wrong. Card numbers written in groups of four hit the same rule.
A single slash, dot, comma, or colon between two numbers is safe: the algorithm glues 19/10/2026 into one number. A hyphen is different. It glues numbers in Hebrew text, but after Arabic letters a range such as 10-20 can show as 20-10, so isolate ranges and dates with hyphens too.
The fix is to say that the whole value is one LTR unit: span dir="ltr" around the phone number, or dir="ltr" on the input that holds it. Phone numbers, card numbers, emails, URLs, code, and file paths are LTR data whatever language surrounds them.
Neutrals at the edges: the name-and-count bug
The second classic bug is on an LTR page. A list shows {name} - 3 photos, and the name is Arabic. The - sits between an RTL name and the number 3, which counts as RTL, so the dash and the 3 join the RTL run. On screen the line reads 3 - EMAN photos: the count has jumped to the other side of the name.
A related bug: a Hebrew comment ending with ! on an English page gets its ! drawn on the right, where a Hebrew reader starts. dir="auto" fixes it by making the comment's base direction RTL. Try all three cases below.
The spaces between digit groups are neutral. Between two numbers in an RTL paragraph they turn RTL, so the groups are laid out from right to left.
Isolates, not embeddings
The fix for inserted values is isolation: the inserted text is laid out as its own small paragraph and acts like a single neutral character to its neighbours. Its letters can no longer pull the dash and the count into its run.
Unicode has two families of invisible control characters for this, and the older one is a trap. Embeddings (LRE, RLE, closed by PDF) set a direction for a span but still let its contents interact with the text around it, so the name-and-count bug survives. Isolates (LRI U+2066, RLI U+2067, FSI U+2068, closed by PDI U+2069) were added in Unicode 6.3 in 2013 to fix exactly this. Prefer isolates everywhere.
| Need | In HTML | In CSS | In a plain string |
|---|---|---|---|
| Value of unknown direction inside a sentence | bdi or span dir="auto" | unicode-bidi: isolate | FSI + value + PDI |
| Value known to be LTR (phone, URL, code) | span dir="ltr" | direction: ltr; unicode-bidi: isolate | LRI + value + PDI |
| Block of user text, each paragraph its own direction | dir="auto" on each paragraph element | unicode-bidi: plaintext | Not applicable |
| Fix one stray punctuation mark | Add an invisible mark | Not applicable | LRM (U+200E) or RLM (U+200F) next to it |
| Force visual order, ignore the algorithm | bdo dir="rtl" | unicode-bidi: bidi-override | RLO ... PDF (rarely right; see chapter 9) |
Some places only take plain strings: document.title, the title and placeholder attributes, push notifications, and canvas text. There, wrap interpolated values in FSI and PDI. Mozilla's Fluent does this for every placeholder by default; with other libraries, do it in your formatting helper.
const FSI = "\u2068"; // first strong isolate
const PDI = "\u2069"; // pop directional isolate
export const isolate = (value: string) => FSI + value + PDI;
// "{name} sent you {count} files" in any language
notify(t("files-received", { name: isolate(sender.name), count }));
document.title = t("inbox-title", { folder: isolate(folder.name) });
Check your understanding · Exercise 5
On an Arabic page, the saved card 4111 1111 1111 1234 shows up as 1234 1111 1111 4111. Which fixes work? Select all that apply.
Forms, keyboards, carousels, and charts
Static layout is the easy half. The harder half is anything that moves or reacts: inputs, arrow-key widgets, carousels, and charts. Here the browser helps less, because the code is yours.
Forms and the input caret
In an RTL form, labels sit at the start (right), the caret starts at the right edge, and typed text grows leftward. That all comes from dir on the page. Three kinds of fields need more care:
- Free text from users (search, comments, chat):
dir="auto", so an English query on an Arabic site aligns left and an Arabic query on an English site aligns right. Browsers update the direction as the user types. Adddirname="q.dir"and the form also submits the direction, so the server can store it and display the text correctly later. - LTR data (email, URL, phone, card number, username, code):
dir="ltr". Otherwiseuser@example.comtyped into an RTL field jumps around as each character arrives, and the caret seems to move backward. Many teams keep these fields visually aligned with the rest of the form, which is a style choice; the direction must be LTR.
Arrow keys follow the screen, not the code
Tabs, toolbars, sliders, and menu bars move with the arrow keys. In RTL the next item is on the left, so ArrowLeft must go to the next item: users press the key that points where they want to go on screen. Hard-coding ArrowRight as "next" is one of the most common RTL bugs in component libraries.
function onKeyDown(e: KeyboardEvent, el: HTMLElement, index: number) {
const rtl = getComputedStyle(el).direction === "rtl";
const next = rtl ? "ArrowLeft" : "ArrowRight";
const prev = rtl ? "ArrowRight" : "ArrowLeft";
if (e.key === next) focusItem(index + 1);
if (e.key === prev) focusItem(index - 1);
// Home and End stay logical: first and last item
}
Carousels and swipe direction
A carousel is a row of slides, and in RTL the row starts at the right. Slide 1 is on the right, the "next" button sits at the inline-end (the left) and points left, and the dots run from right to left. To see the next slide, the user drags their finger from left to right, the opposite of LTR, because the next slide is waiting off the left edge.
A carousel built on native scrolling with CSS scroll snap gets this for free: an overflow-x: auto row in an RTL container starts at the right and swipes the right way. A carousel built on translateX() gets none of it, so its gesture code must flip the meaning of a drag and the sign of the offset.
translateX must flip both the gesture and the offset.Scroll positions have one more trap. In an RTL scroll container, scrollLeft is 0 at the start (scrolled fully right) and becomes negative as you scroll toward the left. That is the standard behavior in all current browsers; older Chrome versions used positive numbers, so old code and old libraries may still assume it. Prefer direction-neutral APIs: el.scrollIntoView({ inline: "start" }) or scrollBy with a sign chosen from the direction.
Charts, timelines, and progress
Charts are drawn in SVG or canvas, where nothing flips by itself. Bar charts and progress indicators usually mirror: bars grow from the start edge and the y-axis moves to the right. Time-series charts are a product decision: some RTL products run time from right to left, while finance and analytics tools often keep it left to right, as their users see it everywhere else. Ask designers who read the language, then be consistent.
Either way, axis numbers stay LTR, the legend follows the page layout, and tooltips must not be placed with raw left coordinates. Positioning libraries take physical placements such as right-start, so store the intent as end and map it from the direction.
Think first
Your carousel uses transform: translateX(-index * 100%) and a pointer handler that goes to the next slide when the drag dx is below -50. What breaks in RTL, and what's the smallest correct change?
In RTL, the slides are laid out from the right, so slide 2 sits to the LEFT of slide 1. Translating by a negative amount moves the track left, which shows empty space on the right instead of slide 2. And the user swipes rightward to see the next slide, producing a positive dx, which your handler treats as "previous".
Compute a sign once from the direction, const s = rtl ? -1 : 1, and use it twice: translate the track by -s * index * 100%, and go next when dx * s < -50. Better still, rebuild the track on native scroll with scroll snap, which follows the direction by itself.
Testing RTL and keeping it fixed
RTL support decays. A team fixes every bug before launch, and three months later a feature ships with margin-left and a hard-coded ArrowRight, because nobody on the team reads Arabic. Testing RTL is about making bugs visible to people who don't read the language, and making the right way the easy way.
Prevent: lint for physical CSS
The cheapest bug is the one that never merges. A Stylelint plugin such as stylelint-use-logical reports margin-left, padding-right, left, text-align: left, and friends, and can auto-fix most of them to their logical forms. Turn it on as an error for new code, and allow exceptions only with a comment that says why, for example a canvas overlay that really is physical.
Tailwind has logical utilities built in: text-start since version 3.1, and since 3.3 ms-4 and me-4 for inline margins, ps-4 and pe-4 for inline padding, start-0 and end-0 for inset, rounded-s-lg, and border-s-4. Use those instead of ml-4, pr-4, and left-0.
For the few things that truly differ, the rtl: and ltr: variants apply a class in one direction only, such as rtl:-scale-x-100 on a directional icon. A lint rule or a code search for ml-, mr-, pl-, and pr- keeps them out.
// stylelint.config.mjs
export default {
plugins: ["stylelint-use-logical"],
rules: {
"csstools/use-logical": ["always", {
// allow the block axis and sizes, which don't flip in horizontal text
except: [/^(margin|padding)-(top|bottom)$/, /^(top|bottom)$/, /^(min-|max-)?(width|height)$/],
}],
},
};
margin-left, right, and text-align: left. By default the plugin also flags top, bottom, width, and height, which many teams allow with except.See it: pseudo-RTL for people who don't read RTL
Most engineers can't tell a correct Arabic screen from a broken one, and translations arrive weeks after the code. Pseudo-RTL solves both: a fake locale that sets dir="rtl" and wraps every English string in characters that force it to display right to left. The UI mirrors and the English text still shows, so anyone can spot an arrow pointing the wrong way.
Android ships one, the ar-XB pseudo-locale, next to the accented en-XA. On the web, add one to your i18n setup next to your accented pseudo-locale (see the translation architecture lesson). A simpler first step is a developer toggle that sets dir="rtl" on the root while leaving the text English. It catches layout and icon bugs, but not bidi bugs, because all the text is still LTR.
Catch it: both directions in every visual test
- Component stories in both directions. Add a direction switch to Storybook (a toolbar global that sets
diron the preview root) and review every new component in both. - Visual regression snapshots per direction. Run the screenshot suite for at least one RTL locale, so a change that breaks only RTL fails CI with a pixel diff that anyone can see.
- Behavior tests for keys and gestures. Run tab, slider, and carousel tests with
dir="rtl"and assert thatArrowLeftmoves to the next item. - Bidi fixtures. Use test data with an Arabic name, a Hebrew sentence ending in punctuation, and a phone number in screenshots of lists and notifications.
Confirm it: a native reader, regularly
Tools catch mechanical bugs. They can't tell you that a chart reads backward to your users or that the Arabic font is too small. Have a native reader walk the main flows before launch and regularly after it, and file what they find with the same priority as LTR bugs.
Check your understanding · Exercise 6
Your team has no Arabic or Hebrew readers. Which practices would catch most RTL regressions before users see them? Select all that apply.
In real products: accessibility, typography, performance, and security
Large products have taken both roads from chapter 4. Wikipedia serves its Arabic, Hebrew, and Persian editions from the same code as English, and MediaWiki flips the stylesheets with CSSJanus. Bootstrap 5 ships a separate bootstrap.rtl.css generated with RTLCSS. Most newer component libraries and Tailwind codebases write logical properties, so one stylesheet serves both directions.
The design guidance has converged too. Material Design and Apple's Human Interface Guidelines both publish right-to-left guides, and they agree on the core rules in this lesson: mirror the layout and the directional icons, and keep media controls, clocks, and numbers as they are.
Accessibility: keep the DOM in logical order
Screen readers read the DOM in order and use lang to pick a voice; the visual side doesn't matter to them. So the DOM stays in reading order in both directions, and mirroring comes from dir and CSS. Reverse an array of tabs in JavaScript for RTL, and the screen reader reads them backward.
The same goes for row-reverse and order used to fake RTL: visual order and focus order disagree. With dir="rtl" and normal flow, focus moves from right to left, as RTL keyboard users expect. Also set lang on passages in another language, so they are read with the right voice.
Typography that doesn't break scripts
- Size and line height. Arabic letters look smaller than Latin at the same font size and need more vertical room. Many teams bump both slightly for Arabic and Persian.
- No letter-spacing on Arabic. Arabic is cursive: letters join. The CSS Text spec says browsers should not add letter-spacing inside cursive scripts, but support varies, and where it is applied it can break the joins. Reset it to
normalfor Arabic-script locales. - A font that has the glyphs. Load an Arabic or Hebrew web font with
unicode-range, so only pages that need it download it.
Performance
Logical properties cost nothing at runtime; a flipped second stylesheet doubles the CSS you build and cache. The real risk is a late flip: if JavaScript sets dir after hydration, the whole layout jumps, which counts as layout shift (CLS, Cumulative Layout Shift). Render dir in the server HTML.
Security: bidi controls as a spoofing tool
The override characters from chapter 6 can lie. The right-to-left override, RLO (U+202E), makes the text after it display backward. An attacker names a file invoice + RLO + fdp.exe, and it displays as invoiceexe.pdf: an executable that looks like a PDF.
In 2021 the Trojan Source research (CVE-2021-42574) used the same characters in source code, so a code review showed different logic from what the compiler ran. GitHub now warns about hidden bidi characters in diffs.
So, where users supply names that other users trust, such as file names, usernames, URLs, and payment references, strip or visibly escape the bidi control characters (U+202A to U+202E and U+2066 to U+2069) before display, or render the value inside an isolate that the override can't escape. Your own isolates, added by your formatting helper, are safe; the attacker's overrides are not.
Think first
A file-sharing app shows uploaded file names in a list. Why is wrapping each name in bdi good for layout but not enough for security?
bdi isolates the name, so an Arabic file name can't scramble the size and date next to it. That is a layout fix.
But the override characters inside the name still work within the isolate: invoice + RLO + fdp.exe still displays as invoiceexe.pdf. For trust decisions, strip or escape bidi controls in the name, and show the real extension in its own column.
Using it in a system design interview
RTL is rarely the whole question. It comes up inside a bigger one: "design a news feed for a global audience" or "design a carousel for our design system". Interviewers listen for whether you handle it at the system level instead of per screen, and whether you know the sharp edges.
Bring it up in the requirements phase as soon as the product is international: "Do we support RTL languages like Arabic or Hebrew? That affects the CSS conventions, the icon set, and the components." Then spend one or two minutes on it in the design, not ten.
What to say, in order
- Direction is decided on the server from the locale's script and rendered on
html. - CSS uses logical properties, enforced by a lint rule; transforms and other leftovers use
:dir(rtl). - Icons carry a mirror flag in the design system.
- User content gets
dir="auto", interpolated values are isolated, and phone numbers getdir="ltr". - Components flip their input mapping: arrow keys, swipes, and scroll offsets.
- Testing: pseudo-RTL locally, RTL visual snapshots in CI, a native reader before launch.
How deep to go by level
| Level | What a strong answer shows |
|---|---|
| Junior | Knows RTL exists, sets dir="rtl", and knows margin-inline-start replaces margin-left |
| Mid | Adds icon mirroring rules, dir="auto" for user content, and the arrow-key and carousel flips in components |
| Senior | Makes it a system: server-rendered direction, lint rules, design-system flags, bidi isolation in the message formatter, RTL visual tests in CI, and the bidi spoofing risk |
A common follow-up is "what about a page that shows both directions at once", like an English admin tool that previews Arabic content. This is where logical properties beat a flipped stylesheet: you can set dir="rtl" on the preview panel only, and every component inside it lays out correctly, because nothing depended on a page-level RTL stylesheet.
Check your understanding · Exercise 7
You're designing a design-system carousel that must work in Arabic. Which points belong in a strong answer? Select all that apply.
Summary
- Set
langanddiron the root element in the server HTML, choosing the direction from the locale's script, never from its region. - Think in start and end, not left and right. Logical properties such as
margin-inline-startmake one stylesheet correct in both directions, and flex and grid already follow the direction. - Transforms, shadows, gradients, scroll offsets, and JavaScript geometry don't flip; handle them with
:dir(rtl)or a direction check. - Mirror icons that point along the reading flow; never mirror media controls, clocks, checkmarks, logos, or digits. Store the decision as a flag in the icon library.
- Mixed-direction text follows the Unicode Bidi Algorithm. Give user content
dir="auto", isolate interpolated values withbdior FSI and PDI, and mark phone numbers and URLsdir="ltr". - Interactive components must flip their input mapping: arrow keys, swipe direction, and
scrollLeft, which is negative in RTL. - Keep the DOM in reading order and let CSS mirror the layout; reversing arrays breaks screen readers and focus order.
- Prevent with a lint rule, see with pseudo-RTL, catch with RTL visual snapshots, and confirm with a native reader.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
We're launching the app in Arabic. Walk me through what changes in the frontend beyond translation.
First, direction: the server picks it from the locale's script and renders html lang="ar" dir="rtl", so the first paint is mirrored and nothing jumps after hydration.
Second, CSS: every physical inline property becomes logical (margin-inline-start, inset-inline-end, text-align: start), enforced by a lint rule. Flex and grid follow by themselves. Transforms, shadows, and JS geometry get explicit :dir(rtl) handling.
Third, the design system: directional icons carry a mirror flag, components flip arrow-key and swipe mappings, and the formatter isolates interpolated values. Finally, testing: pseudo-RTL, RTL visual snapshots in CI, and a native reader review.
Question 2
Why does a phone number show up scrambled on our Arabic page, and how do you fix it everywhere?
The Unicode Bidi Algorithm keeps each digit group left to right, but the spaces between groups are neutral. Between two numbers in an RTL paragraph they resolve to RTL, so the groups are laid out from right to left: +966 55 123 4567 displays as 4567 123 55 966+.
The fix is to mark the value as one LTR unit: span dir="ltr" in markup, dir="ltr" on the phone input, or LRI and PDI in plain strings. To fix it everywhere, put it in the shared PhoneNumber component and the formatting helper rather than in each screen.
Question 3
Logical properties or a generated RTL stylesheet with RTLCSS: which would you pick?
For new code, logical properties: one stylesheet, nothing to build or ship twice, and mixed-direction pages work, such as an Arabic preview inside an English admin.
A generated stylesheet makes sense as a bridge for a large legacy codebase: it flips everything at once, including shadows and transforms. But it doubles the CSS output, hides flip-or-not decisions in comments, and can only make a whole page one direction. I'd use it during a migration while a lint rule moves code to logical properties.
Question 4
Which icons should flip in RTL, and how do you make sure every team gets it right?
Flip icons that point along the reading flow: back and forward arrows, chevrons, list and indent icons, progress along a line. Don't flip icons about real things: media controls, clocks and circular arrows, checkmarks, logos, and anything with digits or text in it.
To make it consistent, the decision lives in the icon library as a mirrorInRtl flag set by a designer, applied by one CSS rule with :dir(rtl). Icons are named by meaning (chevron-end), not by drawing (chevron-right), so the name stays true in both directions.
Question 5
How do you display user-generated content of unknown direction safely on any page?
Blocks of user text get dir="auto", so each comment or message picks its direction from its first strong letter. Values inside a sentence, such as a user name before a count, go in bdi, or between FSI and PDI in plain strings, so they can't reorder the words around them.
For security, strip or escape bidi override characters in names users trust, such as file names and payment references, so an RLO can't make exe look like pdf.
Practice it
These design problems lean on right-to-left layouts.
References
- MDN: CSS logical properties and values Every logical property and how it maps to physical sides.
- MDN: the dir global attribute Values, inheritance, and why the attribute beats the CSS property.
- W3C: Inline markup and bidirectional text in HTML When to use
bdi,dir="auto", and isolates, with worked examples. - Unicode Standard Annex 9: Unicode Bidirectional Algorithm The full algorithm, including isolates and neutral resolution.
- Material Design: Bidirectionality Which layout parts and icons mirror, with pictures.
- MDN: the :dir() pseudo-class Matching elements by their computed direction.
- Trojan Source: invisible vulnerabilities How bidi override characters can disguise code and file names.
Read next
- Translating an appHow an app speaks many languages: text in per-locale message catalogs, ICU messages so plurals and gender work everywhere, catalogs loaded per locale and route, clear fallbacks, and a safe workflow with translators.
- Dates, numbers, and pluralsTurning raw instants, numbers, amounts, and counts into text that reads right for each user with the browser's built-in Intl APIs, instead of hand-built strings that are correct for only one country.
- Locales, routing, and SEOHow a multilingual site picks a language, gives every language its own URL, tells search engines about the copies with hreflang, renders and caches each locale on the server, keeps translation bytes small, and copes with German text that runs 30% longer.
- Semantic HTML firstUsing the HTML element that already means what you need, like
button,a,nav, andh2, so keyboards, screen readers, and voice control understand the page with no extra code. - Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.