cf.completefrontend
My progress
FUNDAMENTALS · MODULE 13

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.

The same Arabic product page twice. Left, with physical CSS: the back arrow points left, the accent border stays on the left edge, the -20% badge sits at the top right on top of the product name, and the phone number reads 4567 123 55 966+. Right, with logical CSS, a mirrored icon, and an isolated phone number: the arrow points right, the border is on the right, the badge moved to the top left, and the phone number reads +966 55 123 4567.physical CSS←SOUQproduct name-20%price, rating, stock4567 123 55 966+4 RTL bugslogical CSS, mirrored→SOUQproduct name-20%price, rating, stock+966 55 123 4567mirrored correctly
The text already runs from the right on both sides because of 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, and text-align: left stays 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?

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.

Two boxes with three lines of text each. In the left-to-right box, the lines start at the left edge, an arrow runs left to right, inline-start is on the left and inline-end on the right. In the right-to-left box, the lines start at the right edge, the arrow runs right to left, inline-start is on the right and inline-end on the left. In both boxes block-start is at the top and block-end at the bottom.dir="ltr"block-start (top)inline-startinline-endblock-end (bottom)dir="rtl"block-start (top)inline-startinline-endblock-end (bottom)
The inline axis is the direction text runs, so its start and end swap sides. The block axis is the way lines stack, top to bottom in both. 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

  1. 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.
  2. 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.
  3. 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.

tsx
// 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>
  );
}
Decide the direction from the script of the language, never from the region: 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 td is the rightmost cell.
  • Flex rows and grid columns start at the right, because flex-start and 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.

A decision tree. Where is the text from? Your own UI strings: set html dir from the locale, on the server. Users or third parties, direction unknown: if the value sits inside a sentence, wrap it in bdi or FSI and PDI; if it is a block of its own, put dir auto on it. LTR data such as phone numbers, URLs, and code: put dir ltr on its element.Where is it from?your UItranslated stringsusers, 3rd partiesunknown directionLTR dataphone, URL, codehtml dirfrom the localein a sentence?dir="ltr"on its elementyesnobdior FSI + PDIdir="auto"on the block
The page direction comes from the locale; everything else gets its own dir. Values inside sentences also need isolation, which bdi and any element with a dir attribute give you.
WhereWhat to writeWhy
Root of the pagehtml lang="ar" dir="rtl"Sets the base direction for everything; render it on the server
A block of user contentp dir="auto"Picks the direction of that text from its first strong letter
A value inside a sentencebdi around the valueAuto direction plus isolation from the surrounding words
Content known to be LTR on an RTL pagedir="ltr" (code, URLs, emails)Keeps a code sample or an email address in its own order
Inputs for user textinput 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.

PhysicalLogicalIn an RTL page it means
margin-left: 16pxmargin-inline-start: 16px16px on the right
padding-right: 8pxpadding-inline-end: 8px8px on the left
padding-left: 8px; padding-right: 24pxpadding-inline: 8px 24px8px on the right, 24px on the left (start first, then end)
left: 0inset-inline-start: 0Pinned to the right edge
border-left: 4px solidborder-inline-start: 4px solidAccent bar on the right
border-top-left-radiusborder-start-start-radiusThe top-right corner is rounded
text-align: lefttext-align: startText aligned right
float: leftfloat: inline-startFloats to the right
width, heightinline-size, block-sizeUnchanged 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.

Page direction
CSS written with
Icons in RTL

border-left: 6px solid; text-align: left; .badge { right: 12px }

A product card with a video thumbnail, a play button, a discount badge, a title, and a View details link with a chevron, drawn right to left with physical CSS and the policy "Mirror nothing". 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.▶-20%Smart watchIn stockView details›4 RTL bugs
English words stand in for the translated text. The image moves in both CSS modes, because grid columns follow 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.

css
.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 */
}
A card with no physical property at all. It is correct in both directions without any [dir] selector.
Eight CSS declarations and what they do in RTL. Follow the direction: margin-inline-start, justify-content flex-start, grid-template-areas, and text-align start. Stay physical: transform translateX, the x offset of box-shadow, the four-value margin shorthand, and background-position left.declarationin an RTL pagemargin-inline-start: 16pxfollows dirjustify-content: flex-startfollows dirgrid-template-areas: "nav main"follows dirtext-align: startfollows dirtransform: translateX(-100%)stays physicalbox-shadow: 4px 2px 8pxstays physicalmargin: 0 8px 0 16pxstays physicalbackground-position: leftstays physical
Logical properties, flexbox, and grid follow 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, ...), and background-position: left are physical.
  • Four-value shorthands. margin: 0 8px 0 16px and inset: 0 auto auto 0 are physical, top, right, bottom, left. Only the -inline and -block shorthands 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).

css
.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?

Logical properties everywhere

Write margin-inline-start, inset-inline-end, text-align: start in every component, enforced by a lint rule.

Handle the few physical leftovers (transforms, shadows) with :dir(rtl).

Pros

  • One stylesheet, no build step
  • Mixed-direction pages work, such as an RTL widget inside an LTR page

Cons

  • A large legacy codebase needs a migration
  • Developers have to learn the new names

Generate a flipped stylesheet at build time

A tool such as RTLCSS or CSSJanus reads the LTR CSS and writes an RTL copy, swapping left and right, flipping shadows and transforms.

The server sends app.rtl.css to RTL locales.

Comments such as /*rtl:ignore*/ mark rules that must not flip.

Pros

  • Works on old code without rewriting it
  • Also flips values logical CSS can't, such as shadows and translateX

Cons

  • Two bundles to build, cache, and ship
  • A page can only be one direction, so embedded opposite-direction content breaks

Hand-written [dir="rtl"] overrides

Keep physical CSS and add an override rule per component for RTL.

Pros

  • No tooling at all

Cons

  • Every new rule needs a twin that nobody remembers, so RTL bugs return with every feature

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.
Two groups of icons, each shown in LTR and RTL. Mirror in RTL: the back arrow points left in LTR and right in RTL, the chevron points right in LTR and left in RTL, a progress bar fills from the left in LTR and from the right in RTL, and a slider's filled part starts at the left in LTR and at the right in RTL. Keep as drawn: play, refresh, check mark, and the digits 30 look the same in both.mirror in RTLLTRRTLback←→chevron›‹progresssliderkeep as drawnLTRRTLplay▶▶refresh↻↻check✓✓digits3030
Mirror what points along the reading flow. Keep what shows something real, such as a tape playing, a clock turning, or a number, which reads the same in every language.

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.

tsx
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); } */
The flag lives with the icon, and one CSS rule applies it. Name icons by meaning (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

  1. Find the base direction of the paragraph. It comes from dir on the element (or from the first strong letter with dir="auto"). The base gets an embedding level: 0 for LTR, 1 for RTL. Even levels are LTR, odd levels are RTL.
  2. 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.
  3. 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.
  4. 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.
An Arabic sentence, written here in capitals, with the English word iphone and the number 16, in an RTL paragraph. Stored: BUY, space, iphone, space, 16, space, NOW. Types: AL (Arabic letter), neutral, L, neutral, European number, neutral, AL. Resolved levels: 1, 1, 2, 2, 2, 1, 1. Drawn from left to right: WON, space, iphone, space, 16, space, YUB. The Arabic words are reversed and the English run with its number stays in reading order.storedBUYspiphonesp16spNOWtypeALNLNENNALlevel1122211drawnWONspiphonesp16spYUB
Odd levels are RTL and get reversed; even levels are LTR. The 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.

Example
Phone in Arabic text. Stored in memory: CALL + 966 55 123 4567. Displayed in a right-to-left paragraph: 4567 123 55 966 + LLAC, which is wrong.stored in memory, in reading orderCALL+966551234567drawn on screen, paragraph dir="rtl"456712355966+LLACreads wrong
Capital letters stand for Arabic or Hebrew letters, so an RTL word shows reversed on screen. The top row is the order in memory; the bottom row is the order the browser draws, from left to right.

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.

NeedIn HTMLIn CSSIn a plain string
Value of unknown direction inside a sentencebdi or span dir="auto"unicode-bidi: isolateFSI + value + PDI
Value known to be LTR (phone, URL, code)span dir="ltr"direction: ltr; unicode-bidi: isolateLRI + value + PDI
Block of user text, each paragraph its own directiondir="auto" on each paragraph elementunicode-bidi: plaintextNot applicable
Fix one stray punctuation markAdd an invisible markNot applicableLRM (U+200E) or RLM (U+200F) next to it
Force visual order, ignore the algorithmbdo dir="rtl"unicode-bidi: bidi-overrideRLO ... 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.

ts
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. Add dirname="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". Otherwise user@example.com typed 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.

ts
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.

A sequence diagram of a swipe in an RTL carousel. The finger drags right by 120 pixels. The gesture code sees an RTL container, so a positive drag means next. The carousel moves from slide 1 to slide 2. The track translates by plus 100 percent and shows slide 2, which enters from the left. A note compares LTR, where next is a negative drag and translateX of minus 100 percent, with RTL, where next is a positive drag and translateX of plus 100 percent.fingergesturecarouseltrackdrag right, dx +120rtl: +dx is nextslide 1 to 2shows slide 2LTR: next when dx < 0, translateX(-100%)RTL: next when dx > 0, translateX(100%)
In RTL the next slide waits off the left edge, so the user drags right and the track moves right. Native scroll snap does this by itself; code built on 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?

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.

js
// 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)$/],
    }],
  },
};
A Stylelint config that flags physical inline properties such as 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 dir on 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 that ArrowLeft moves 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.
Four layers of RTL testing, from earliest to latest. Prevent at review: Stylelint with use-logical, Tailwind logical utilities, and a code search for hard-coded ArrowRight. See while building: a pseudo-RTL locale and Storybook with a direction toolbar. Catch in CI: screenshots per direction and keyboard tests with RTL fixtures. Confirm before launch: a native reader walks the main flows.prevent: reviewStylelintuse-logicalTailwindms- me- ps- pe-code searchArrowRightsee: buildingpseudo-RTLfake RTL localeStorybookdir toolbarcatch: CIscreenshotsper directionkey testsdir=rtl fixturesconfirm: launchnative readermain flows
Each layer catches what the one above lets through. The early layers are cheap and run on every change; the native reader is slow and catches what no tool can, such as a chart that reads backward.

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 normal for 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?

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.

A 45-minute interview as a timeline. Minutes 0 to 5, requirements: ask whether RTL languages are in scope. Minutes 5 to 20, high-level design: direction from the locale, rendered on the server. Minutes 20 to 35, deep dive: logical CSS, icon mirror flags, and flipped keys and swipes in components. Minutes 35 to 45, wrap-up: pseudo-RTL and RTL screenshots in testing.a 45-minute interview, in minutes05203545reqsask: RTLlanguages?designdir fromlocale, SSRdeep divelogical CSS,icon flags,keys, swipewrap-uppseudo-RTL,snapshots
RTL is a thread through the interview, not a section of it: one question early, one sentence in the design, and the details where components come up.

What to say, in order

  1. Direction is decided on the server from the locale's script and rendered on html.
  2. CSS uses logical properties, enforced by a lint rule; transforms and other leftovers use :dir(rtl).
  3. Icons carry a mirror flag in the design system.
  4. User content gets dir="auto", interpolated values are isolated, and phone numbers get dir="ltr".
  5. Components flip their input mapping: arrow keys, swipes, and scroll offsets.
  6. Testing: pseudo-RTL locally, RTL visual snapshots in CI, a native reader before launch.

How deep to go by level

LevelWhat a strong answer shows
JuniorKnows RTL exists, sets dir="rtl", and knows margin-inline-start replaces margin-left
MidAdds icon mirroring rules, dir="auto" for user content, and the arrow-key and carousel flips in components
SeniorMakes 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 lang and dir on 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-start make 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 with bdi or FSI and PDI, and mark phone numbers and URLs dir="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.

Question 2

Why does a phone number show up scrambled on our Arabic page, and how do you fix it everywhere?

Question 3

Logical properties or a generated RTL stylesheet with RTLCSS: which would you pick?

Question 4

Which icons should flip in RTL, and how do you make sure every team gets it right?

Question 5

How do you display user-generated content of unknown direction safely on any page?

Practice it

These design problems lean on right-to-left layouts.

References

Read next