RTL Support: What Breaks and How CSS Logical Properties Fix It
What actually breaks on a right to left website, and how CSS logical properties, bidi isolation and a single –flip variable fix RTL CSS for good. With code.
- CSS logical properties (
margin-inline-start,inset-inline-end,border-start-start-radius,text-align: start) remove roughly 80 percent of direction-specific CSS and let one stylesheet serve both directions, including mixed-direction subtrees on the same page. - Logical properties do not flip
transform,box-shadow,background-position, gradients,clip-path, or SVG paths. Drive those from a single inherited custom property such as--flip: 1 / -1set on[dir]. - Bidi text is the hardest part and has nothing to do with layout. Wrap any string whose direction differs from its surroundings in
<bdi>, and forcedirection: ltr; unicode-bidi: isolateon code, URLs and email inputs. scrollLeftis negative in RTL in all current engines, so carousel and scroll-shadow code written withMath.max(0, x)silently dies. ReadgetComputedStyle(el).directionand multiply.- Arabic needs more line-height and a slightly larger optical size than Latin,
letter-spacingdamages cursive joining, andtext-transform: uppercasedoes nothing. Fix these in the font stack, not in per-language overrides.
What actually breaks on a right to left website
Flip dir="rtl" on <html> and watch what survives. Flex rows reverse, grid columns reverse, justify-content: flex-start moves to the right edge, list markers move, and text aligns correctly. That part is free.
The failures, in roughly the order you find them:
- Absolutely positioned decoration. Badges, close buttons, dropdown carets, quote marks, notification dots. All pinned with
right: 12px. - Asymmetric spacing. Icon-plus-label pairs built with
margin-right: 8pxon the icon. In RTL the gap lands on the wrong side and the pair reads as two separate elements. - Border accents.
border-left: 3px solidon blockquotes, alerts and active nav items. - Asymmetric radii. Input-plus-button groups, tabs, chat bubbles.
- Anything transformed. Off-canvas menus using
translateX(-100%), chevrons usingrotate(-90deg), hover lifts withtranslateX. - Directional icons. Back arrows, undo/redo, send, indent, tree disclosure triangles, progress arrows.
- Shadows and gradients.
box-shadow: 4px 4pxandlinear-gradient(to right ...)used as a scroll fade on the wrong edge. - Bidi text. Punctuation, parentheses and digits jumping position inside mixed Arabic and Latin strings.
- JavaScript. Anything using
scrollLeft,offsetLeft,clientXarithmetic, or a positioning library configured withleft/right.
Logical properties kill items one to four outright. The rest need work.

The CSS logical properties map that replaces most of your rtl css
The mental model: inline is the axis text flows along, block is the axis lines stack along. start and end resolve against direction and writing-mode. In horizontal-tb with direction: ltr, inline-start is left. In RTL it is right. Your CSS stops caring.
/ Before: two stylesheets, or a pile of [dir="rtl"] overrides /
.alert {
padding: 1rem 1.25rem;
border-left: 3px solid var(--accent);
border-top-left-radius: 8px;
text-align: left;
margin-left: 0;
margin-right: auto;
}
/ After: one stylesheet, both directions /
.alert {
padding-block: 1rem;
padding-inline: 1.25rem;
border-inline-start: 3px solid var(--accent);
/ block axis first, inline axis second: top-left in LTR, top-right in RTL /
border-start-start-radius: 8px;
text-align: start;
margin-inline: 0 auto;
}
.alert__dismiss {
position: absolute;
inset-block-start: .5rem;
inset-inline-end: .5rem;
}
The substitution table you will use daily:
margin-left/margin-righttomargin-inline-start/margin-inline-end, or the shorthandmargin-inline: 1rem 2remleft/righttoinset-inline-start/inset-inline-end;top/bottomtoinset-block-start/inset-block-end; all four toinsetborder-top-left-radiustoborder-start-start-radius(and so on for the other three corners)width/heighttoinline-size/block-size, plus themin-andmax-variantstext-align: lefttotext-align: startfloat: lefttofloat: inline-start,clear: lefttoclear: inline-start
Browser support is a non-issue for everything except the logical float and clear values. The spacing, inset, radius and sizing properties have been in every evergreen engine since 2021 or earlier. Logical float values were in Firefox for years but Chromium only shipped them in 2024, so if your analytics still show meaningful traffic from Chrome 120 and below, keep float: left with a [dir="rtl"] override or, better, stop floating things.
One real benefit people miss: logical properties resolve per element, so a single stylesheet supports mixed direction. An Arabic site with an embedded English case study, a dashboard with an LTR code panel, a comment thread with both languages. Postprocessors that generate bootstrap.rtl.css cannot do that. The flip is baked in at build time and applies to the whole document. If you use Bootstrap 5 utilities, note that they already use the logical naming (ms-3, me-3, text-start, border-start), which is why the Bootstrap-based Canvas template can ship RTL demos without a parallel set of components. Tailwind users get the same via ms-, me-, ps-, pe-, start-, end- and the rtl: variant.
When a postprocessor is still the right call
If you have inherited 40,000 lines of Sass with physical properties everywhere and the client wants Arabic in three weeks, run RTLCSS and ship. It works, it has been in production on thousands of sites, and /rtl:ignore/ lets you exempt the cases it gets wrong. Convert to logical properties component by component afterwards. The honest trade-off: a second CSS file and no per-subtree direction support. For a single-language-per-page site, that costs you nothing.
What logical properties do not fix
There is no transform-inline, no box-shadow-inline, no logical background-position. For those, use one inherited custom property as the single source of truth:
:root, [dir="ltr"] { --flip: 1; }
[dir="rtl"] { --flip: -1; }
/ Off-canvas drawer: slides in from the inline-start edge in both directions /
.drawer {
inset-inline-start: 0;
transform: translateX(calc(var(--flip) * -100%));
}
.drawer.is-open { transform: translateX(0); }
/ Accordion chevron: points inline-end when closed, down when open /
.accordion__icon { transform: rotate(calc(var(--flip) * -90deg)); }
.accordion[open] .accordion__icon { transform: rotate(0deg); }
/ Scroll fade on the inline-end edge of a horizontal scroller /
.scroller::after {
background: linear-gradient(to right,
transparent, var(--bg) calc(50% + var(--flip) * 50%));
}
Because --flip inherits, setting dir="ltr" on an embedded English subtree fixes every transform inside it automatically. The :dir(rtl) pseudo-class (shipped across all engines by Chrome 120 in late 2023) is cleaner when you need a selector rather than a value, since it also matches elements whose direction came from dir="auto" rather than an explicit attribute.
Icons deserve a decision, not a blanket rule. Flip arrows, chevrons, undo/redo, send, indent/outdent, and any icon depicting progress through a sequence. Leave checkmarks, clocks, search magnifiers, logos and media playback controls alone. Apple’s HIG and Material both land on leaving play, pause and scrubbers unmirrored. Mirroring them generates support tickets.
Bidi text: the bug reports you will actually get
This is the part that has nothing to do with CSS layout. It is entirely about the Unicode Bidirectional Algorithm. Neutral characters (spaces, punctuation, parentheses, slashes) take their direction from the surrounding strong characters. Mix scripts and they move.
The canonical bug: an Arabic sentence containing an English company name with a trailing period. The period belongs to the Arabic sentence but sits next to Latin letters, so it renders on the wrong side. Fix it with isolation:
<!-- Punctuation stays put, name renders LTR inside RTL text -->
<p>تم تحديث الحساب بواسطة <bdi>Acme Inc. (EU)</bdi> اليوم.</p>
<!-- User-generated content of unknown direction -->
<li><bdi><?= $comment->author ?></bdi>: <span dir="auto"><?= $comment->body ?></span></li>
/ Code, URLs, emails and phone numbers are always LTR, even in Arabic UI /
pre, code, samp, kbd,
input[type="email"], input[type="url"], input[type="tel"], .mono {
direction: ltr;
text-align: start; / start of the LTR box, i.e. left /
unicode-bidi: isolate; / keeps neighbouring punctuation from migrating /
}
Two caveats worth knowing. dir="auto" picks direction from the first strong directional character in the string, so a comment that opens with “OK, ” and continues in Arabic will be laid out LTR. If that matters, detect direction server-side per paragraph and store it. And unicode-bidi: isolate on a flex or grid item is fine, but on an inline element inside heavily mixed text you sometimes want plaintext on the container instead, which applies first-strong detection per paragraph.
Digits are a product decision, not a technical one. Intl.NumberFormat('ar-EG') gives Arabic-Indic numerals, ar-SA and most Gulf locales give Western digits. Ask the client. Do not guess and do not hardcode.
Typography traps specific to Arabic and Hebrew
Arabic glyphs have deeper descenders, taller ascenders and no capitals. At the same font-size as Latin, Arabic reads smaller and feels cramped. Set an optical size bump and looser leading on the root rather than patching individual components:
:root { --body-size: 1rem; --body-leading: 1.55; }
:root:lang(ar), :root:lang(fa), :root:lang(ur) {
--body-size: 1.0625rem; / ~17px against a 16px Latin baseline /
--body-leading: 1.85; / Arabic needs the extra room, not a nicety /
}
body { font-size: var(--body-size); line-height: var(--body-leading); }
/ letter-spacing breaks cursive joining in Arabic. Scope it to Latin only. /
.btn, .eyebrow { letter-spacing: .04em; }
:lang(ar) .btn, :lang(ar) .eyebrow { letter-spacing: normal; text-transform: none; }
text-transform: uppercase is a no-op in Arabic, so an all-caps design system silently loses its visual hierarchy in the Arabic build. Give those elements a weight or size difference too. Also watch your font loading: adding Noto Kufi Arabic or IBM Plex Sans Arabic alongside your Latin family can double the font payload, and the fallback metrics rarely match, causing its own layout shift problem (the mechanics are covered in Font Loading Strategy). Use unicode-range so the Arabic subset only downloads when Arabic glyphs are actually rendered.
JavaScript, scroll and third-party widgets
The CSSOM spec made scrollLeft negative in RTL and all three engines now agree. Code like el.scrollLeft = Math.max(0, el.scrollLeft - step) refuses to scroll at all in Arabic. Write direction-aware helpers once:
const inlineStep = (el) => {
// -1 in RTL, where scrollLeft runs from 0 down to -scrollWidth
const sign = getComputedStyle(el).direction === 'rtl' ? -1 : 1;
return sign el.clientWidth 0.9;
};
nextBtn.addEventListener('click', () => {
scroller.scrollBy({ left: inlineStep(scroller), behavior: 'smooth' });
});
// Or skip the maths entirely: the browser resolves 'start' logically
card.scrollIntoView({ inline: 'start', block: 'nearest', behavior: 'smooth' });
Everything in the DOM geometry APIs stays physical. getBoundingClientRect().left is still the left edge of the viewport. Before adopting a tooltip or dropdown library, check whether its placement options resolve start/end logically or only left/right. Retrofitting that is painful. Charting libraries almost always need an explicit axis reversal flag. Embedded third parties (payment element iframes, maps, captcha) take a locale, not your dir attribute, so pass it.
Container-based layouts behave well here because container-type: inline-size is already axis-aware, which is one more reason to prefer them over viewport breakpoints in bidirectional work. If you have not moved yet, see Container Queries Are Ready.
Testing it so it stays fixed
RTL regressions are nearly always introduced by a developer who never looked at the Arabic build. Make looking cheap.
- A one-key toggle. Ship a dev-only bookmarklet or query parameter:
document.documentElement.dir = document.documentElement.dir === 'rtl' ? 'ltr' : 'rtl'. If flipping direction takes more than a second, nobody does it. - Real Arabic or Hebrew content in staging, not reversed Latin. Mirrored lorem hides font fallback, line-height and joining problems entirely.
- Visual diffs in both directions. Our Playwright suite parameterises a
dirfixture and snapshots each component twice. It roughly doubles the screenshot count and catches the badge-on-the-wrong-corner class of bug before review. - Lint the physical properties out. A stylelint plugin such as
stylelint-use-logical-specflagsmargin-left,left,border-top-left-radiusand friends. Set it to warn on legacy files and error on new ones. - Grep the diff. In code review, searching a PR for
left:andtranslateXfinds in ten seconds what a reviewer eyeballing CSS will miss.
One place to deliberately stay physical: HTML email. Outlook’s rendering engine does not support logical properties, so email templates keep inline physical styles plus dir="rtl" on the table structure. Same for anything targeting a PDF renderer older than a couple of years, which is its own category of pain (see Print Stylesheets and PDF Output).
Frequently Asked Questions
Can I use CSS logical properties everywhere in 2026 without fallbacks?
Yes, for spacing, insets, radii, sizing and text-align: start. Those have been supported in Chrome, Edge, Firefox and Safari for several years, and there is no partial-support trap. The single exception worth checking is float: inline-start and clear: inline-start, which Chromium only shipped in 2024, so older Chrome and Android WebView builds ignore them.
Should I use RTLCSS or logical properties?
Logical properties for anything you are writing now, RTLCSS for large legacy codebases under deadline. Logical properties give you one stylesheet, support mixed-direction subtrees, and survive refactors; a postprocessor gives you a working Arabic build this week without touching 40,000 lines of Sass. Converting component by component afterwards is a reasonable migration path.
Why do my dates and phone numbers render with the symbols in the wrong place?
Because digits are weakly directional and symbols like +, / and ( are neutral, so the bidi algorithm places them according to surrounding text rather than according to the number. Wrap the value in <bdi> or apply unicode-bidi: isolate with direction: ltr to its container. Formatting through Intl.DateTimeFormat with the correct locale also helps, since it emits the right separators and digit set.
Do I need separate templates or a separate site for a right to left website?
No, and doing so is usually the wrong choice because the two copies drift within a release or two. One template, dir and lang set from the active locale, logical CSS, and a --flip variable for transforms covers nearly everything. The only genuine forks tend to be icon assets that need mirrored artwork and marketing imagery with baked-in text.
Which icons should actually be mirrored in RTL?
Anything that encodes direction of travel or sequence: back and forward arrows, chevrons, undo and redo, send, indent, tree disclosure, progress indicators, and the slant on an unbalanced icon like a rising chart. Leave checkmarks, clocks, logos, brand marks and media playback controls alone. When unsure, mirror it and show it to a native reader, since the answer is cultural rather than technical.
Starting a bidirectional project this quarter? Do two things before writing any components. Set --flip and the logical-property lint rule on day one. Retrofitting them after sixty components exist costs several days instead of an hour. Then put real Arabic or Hebrew content in staging and keep a direction toggle one click away. Everything else on this list is a mechanical fix. The expensive bugs are the ones nobody saw because nobody ever loaded the other direction.

