There’s a special kind of pain reserved for developers who ship a polished LTR interface and then get a ticket that says "please support Arabic." You open the stylesheet, see four hundred margin-left declarations, and feel the weight of every poor architectural decision your project ever made.
Supporting RTL isn’t hard — but it requires building with the right mental model from day one. And even if you’re retrofitting, knowing what to actually fix (versus what you can safely ignore) is half the battle.
This guide covers the three pillars of solid RTL support: CSS logical properties, proper handling of bidirectional text, and the often-misunderstood rules around icon mirroring.
Why Physical Properties Are a Trap
The instinct when writing CSS is to think in physical screen coordinates: left, right, top, bottom. That’s the world margin-left, padding-right, border-left, and left: 0 live in. These are called physical properties — they are hardwired to the physical viewport, completely ignorant of text direction.
When you flip dir="rtl" on your HTML, the browser reverses the visual flow of text and inline content. But it does nothing to your physical CSS declarations. Your .sidebar { margin-left: 16px } stays exactly where it is — now floating on the wrong side of everything.
The fix isn’t to write two stylesheets and swap them. The fix is to stop thinking in left/right entirely.
CSS Logical Properties: The Mental Model Shift
Logical properties use two axes:
- Inline axis — the direction text flows (horizontal in Latin, Hebrew, Arabic; vertical in some East Asian writing modes)
- Block axis — the direction blocks stack (vertical in most writing systems)
Every physical property has a logical counterpart. Here’s the mapping you’ll use 90% of the time:
/* Physical → Logical */
/* Margin */
margin-left → margin-inline-start
margin-right → margin-inline-end
margin-top → margin-block-start
margin-bottom → margin-block-end
/* Shorthand — saves a lot of typing */
margin-left + margin-right → margin-inline
margin-top + margin-bottom → margin-block
/* Padding — same pattern */
padding-left → padding-inline-start
padding-right → padding-inline-end
/* Borders */
border-left → border-inline-start
border-left-width → border-inline-start-width
border-left-color → border-inline-start-color
/* Positioning */
left → inset-inline-start
right → inset-inline-end
top → inset-block-start
bottom → inset-block-end
/* Text alignment */
text-align: left → text-align: start
text-align: right → text-align: end
/* Float */
float: left → float: inline-start
float: right → float: inline-end
In an LTR context, margin-inline-start behaves identically to margin-left. In an RTL context, it automatically maps to the right side. You write it once, it works everywhere.
Here’s what a typical card component looks like before and after the conversion:
/* Before — physical, breaks in RTL */
.card {
padding: 16px;
border-left: 4px solid var(--accent);
margin-left: 24px;
text-align: left;
}
.card__icon {
margin-right: 8px;
float: left;
}
/* After — logical, direction-aware */
.card {
padding: 16px; /* block shorthand — fine, top/bottom don't flip */
border-inline-start: 4px solid var(--accent);
margin-inline-start: 24px;
text-align: start;
}
.card__icon {
margin-inline-end: 8px;
float: inline-start;
}
Positioning Gotcha: inset Shorthand
inset is the logical shorthand for all four positions. But its argument order follows physical TRBL (top, right, bottom, left), not logical axes. Don’t confuse it with inset-inline or inset-block, which are the logical versions you actually want for directional positioning.
/* Physical — locks to viewport coordinates */
.tooltip { position: absolute; left: 0; top: 0; }
/* Logical — shifts with direction */
.tooltip { position: absolute; inset-inline-start: 0; inset-block-start: 0; }
The border-radius Situation
Logical border-radius properties (border-start-start-radius, border-start-end-radius, border-end-start-radius, border-end-end-radius) exist in the spec and have good browser support now, but the naming is genuinely confusing. border-start-start-radius means "the corner at the start of the block axis and the start of the inline axis" — which in LTR is top-left, in RTL is top-right. Use them when the rounding is semantically tied to the start/end of content. For decorative radii that should stay symmetric, physical shorthand (border-radius: 8px) is fine.
Setting Up RTL Correctly
This is where most tutorials get it wrong. There are two mechanisms for declaring direction, and they’re not interchangeable.
Use the HTML dir attribute for document-level and component-level direction. Put it on <html> for site-wide direction, or on any element to scope a direction change.
<!-- Site is RTL -->
<html lang="ar" dir="rtl">
<!-- Isolated RTL block inside an LTR page -->
<div dir="rtl" lang="ar">
<p>هذا نص عربي</p>
</div>
The CSS direction property is for special cases only. It doesn’t affect the HTML bidi algorithm the same way the dir attribute does. If you set direction: rtl in CSS without the dir attribute, punctuation and mixed-script text will behave unexpectedly because the browser’s Unicode Bidi Algorithm doesn’t see the CSS value the same way.
Rule of thumb: set direction in HTML, handle layout in CSS with logical properties.
Bidirectional Text: The Part Everyone Skips
Supporting RTL isn’t just about flipping layout. Real-world content is messy. An Arabic e-commerce site shows Arabic product descriptions with English product names, English prices, English URLs in the copy. This is bidirectional text, and the browser has a sophisticated algorithm for rendering it — the Unicode Bidi Algorithm (UBA).
The UBA automatically handles a lot: Arabic characters flow right-to-left, Latin characters embedded in Arabic text flow left-to-right within their run. You mostly don’t need to fight it. But there are edge cases where you do.
<bdi> — Bidirectional Isolate
Use <bdi> when you’re inserting text whose direction is unknown at render time — user-generated content, database values, usernames. Without it, a username like "خالد Smith" embedded in a sentence can scramble the surrounding text’s punctuation and spacing depending on how the UBA resolves the mixed directionality.
<!-- Without bdi: "Posted by خالد Smith:" can render strangely in an LTR context -->
<p>Posted by <span>خالد Smith</span>: great article</p>
<!-- With bdi: isolated, direction resolved independently -->
<p>Posted by <bdi>خالد Smith</bdi>: great article</p>
<bdi> is the semantic HTML element for this. In CSS, the equivalent is unicode-bidi: isolate.
<bdo> — Override Direction
<bdo> forces a specific direction, ignoring the UBA entirely. It’s rarely the right tool — if you think you need it, you probably need <bdi>. The one legitimate use case: displaying a string character-by-character in reverse order for visual/artistic purposes.
<!-- Force LTR even inside an RTL container -->
<bdo dir="ltr">This is forced LTR</bdo>
dir="auto" — Let the Browser Decide
For input fields and textareas where users might type in any language, dir="auto" tells the browser to sniff the first strongly-directional character and set direction accordingly. This is the right default for any user-input surface in a multilingual app.
<input type="text" dir="auto" placeholder="Type in any language...">
<textarea dir="auto"></textarea>
Numbers Always Flow LTR
Inside RTL text, numbers — phone numbers, dates, prices — always render left-to-right. This is correct per Unicode. Don’t fight it. +7 (495) 123-45-67 inside Arabic prose will visually appear on the left side of the text block and read LTR within its run. That’s expected behavior.
Where it gets confusing: a phone number at the end of an Arabic sentence gets pulled to the left visually, but it reads fine. Add a direction mark if the punctuation ends up in the wrong place:
<!-- Arabic sentence ending in a number — the period/full stop can land wrong -->
<p dir="rtl">الهاتف: ‏+7 495 123-45-67</p>
‏ is the Right-to-Left Mark (RLM), a zero-width character that hints the UBA about directionality at that point. Use sparingly and only when you see actual rendering bugs.
Icon Mirroring: The Rules Are Simpler Than You Think
This is the topic most developers get wrong. The instinct is to mirror everything when switching to RTL, or to mirror nothing. The actual rule is conceptual: mirror icons that represent directionality or spatial orientation relative to reading direction. Don’t mirror icons that represent physical objects or universal symbols.
Mirror These
Icons that point in a direction that implies "start" or "end" of a reading flow should flip:
- Navigation arrows (back/forward, previous/next)
- Chevrons used for list navigation
- Breadcrumb separators
- Pagination arrows
- Text alignment icons (align-left becomes align-right)
- Reply/forward email arrows
- Undo/redo (these represent time + reading flow — debated, but Google mirrors them)
- Search field icon when it’s inside an input (the magnifier sits at the start of the field)
- "Open menu" hamburger — no. The arrow it opens toward — yes.
Don’t Mirror These
- Play/pause/stop buttons — these map to physical tape transport controls. A play button always points right.
- Logos and brand marks — never.
- Checkmarks, X marks, warning triangles — universal, direction-neutral.
- Camera, phone, printer, floppy disk — physical objects. The floppy disk slot faces the same way regardless of language.
- Clock icons — clocks run clockwise in every culture. Don’t mirror.
- Progress bars — they fill in the direction of progress, which is always visually LTR even in RTL layouts (though RTL progress bars start from the right edge — use
directionon the container, not icon mirroring). - Charts and graphs — the Y-axis is on the left in RTL too. Seriously.
The Implementation
The cleanest approach is an attribute selector on [dir="rtl"]. No JavaScript, no class toggling:
/* Target mirrorable icons specifically — don't blast everything */
[dir="rtl"] .icon--arrow-right,
[dir="rtl"] .icon--chevron-right,
[dir="rtl"] .icon--back,
[dir="rtl"] .icon--forward,
[dir="rtl"] .icon--reply {
transform: scaleX(-1);
}
If you’re using an icon system with data attributes:
[dir="rtl"] [data-icon-mirror="true"] {
transform: scaleX(-1);
}
Then in your icon component markup:
<!-- This icon mirrors in RTL -->
<svg class="icon" data-icon-mirror="true" aria-hidden="true">
<use href="#icon-arrow-right" />
</svg>
Gotcha with transforms: scaleX(-1) stacks with any existing transform on the element. If you have a hover animation that does transform: translateX(4px), the RTL flip breaks it. Use a wrapper element for the flip transform, or convert to transform: scaleX(-1) translateX(4px) explicitly.
SVG transform attribute vs CSS: For inline SVGs, prefer the CSS approach over hardcoding transform in the SVG markup. The SVG attribute is static; CSS can respond to the document’s dir.
What About Icon Fonts?
Icon fonts are a pain for RTL because you can’t apply scaleX(-1) without also flipping the pseudo-element box. Wrap the icon in a <span> and transform the span:
[dir="rtl"] .icon-wrapper--mirror {
display: inline-block;
transform: scaleX(-1);
}
This is one of many reasons to move to SVG icons if you haven’t.
Practical Setup: A Logical-First Stylesheet Baseline
If you’re starting a new project, set these defaults upfront:
/* globals.css */
/* Reset physical text-align defaults */
*, *::before, *::after {
box-sizing: border-box;
}
/* Input elements default to physical text-align: left — override it */
input,
textarea,
select,
button {
text-align: start;
}
/* Ensure the document-level direction is respected */
html {
/* Set in HTML: <html dir="rtl" lang="ar"> */
/* CSS direction alone is not enough */
}
/* Scrollbar placement follows writing direction in modern browsers */
/* No action needed — it's automatic with dir="rtl" on <html> */
For Sass users, here’s a mixin pattern that writes logical properties with a physical fallback for very old browsers:
// _mixins.scss
@mixin margin-inline($start, $end: $start) {
margin-inline-start: $start;
margin-inline-end: $end;
}
@mixin padding-inline($start, $end: $start) {
padding-inline-start: $start;
padding-inline-end: $end;
}
@mixin border-inline-start($value) {
border-inline-start: $value;
}
Browser support for CSS logical properties is excellent — all major engines have supported the core properties since 2021. The one area to watch is overflow-inline and overflow-block (Safari lagged here but caught up), and some logical shorthand edge cases in older Safari versions.
Gotchas Worth Knowing Before You Ship
background-position has no logical equivalent yet. If you’re positioning a background image from the left, there’s no background-position-inline-start. The workaround is to use background-position-x with left and override it in an [dir="rtl"] block, or use 50% if the position is symmetric. This is an active gap in the spec.
Flexbox and Grid are already logical. justify-content: flex-start and align-items: flex-start in a flex row respect the document direction — flex-start is already the inline start. You don’t need logical properties on the flex container itself. Where you do need them is on flex items for their own margins and positioning.
absolute positioning needs both inset-inline-start and explicit position reset. A common bug: you set inset-inline-start: 0 but forget to unset left: 0 from an earlier rule. Both apply, and left wins in specificity in some browsers. Audit for any residual physical positioning declarations.
Don’t use direction: rtl on individual elements to mirror layout without also setting it on the text content. You’ll get a block that visually renders right-to-left but has LTR inline text — the layout will flip but the text won’t, resulting in a mirrored mess.
Testing: don’t rely on machine translation. The best way to test RTL is to load the actual dir="rtl" attribute and fill the page with RTL placeholder text. bidilorem.com generates Arabic Lorem Ipsum. Chrome DevTools doesn’t have a one-click RTL toggle, but you can run document.documentElement.dir = 'rtl' in the console to test your CSS without changing code.
Scrollbar position is automatic — when dir="rtl" is on <html>, the vertical scrollbar moves to the left in Chrome and Firefox. Safari doesn’t do this (as of mid-2025). Custom scrollbars via ::-webkit-scrollbar will need manual positioning adjustments.
Production-Ready Checklist
Before shipping RTL support, run through this:
[ ] <html dir="rtl" lang="..."> set correctly — not just CSS direction
[ ] All margin-left/right → margin-inline-start/end
[ ] All padding-left/right → padding-inline-start/end
[ ] All border-left/right → border-inline-start/end
[ ] text-align: left/right → text-align: start/end
[ ] float: left/right → float: inline-start/end
[ ] Absolute-positioned elements use inset-inline-* not left/right
[ ] <bdi> around user-generated or dynamic text
[ ] dir="auto" on all text inputs and textareas
[ ] Icons audited: directional ones flip, physical ones don't
[ ] Icon transform stacking verified (no broken hover animations)
[ ] background-position hardcodes manually handled
[ ] No residual physical properties overriding logical ones
[ ] Fonts for RTL scripts loaded (Arabic, Hebrew need specific weights)
[ ] Tested in Chrome, Firefox, Safari with actual RTL content
The font point deserves emphasis: Arabic script requires fonts that have proper ligature support and Arabic-script glyphs. Don’t assume your Latin body font has Arabic coverage. Load a proper Arabic web font — Noto Sans Arabic is a safe default, IBM Plex Arabic is a good choice for technical interfaces.
RTL support done right is mostly about discipline at the declaration level: logical properties everywhere, HTML dir attribute for direction, <bdi> for dynamic content, and a clear mental model for which icons should mirror. There’s no magic — just consistent application of the right primitives.
The developers who struggle with RTL are usually the ones who retrofitted it onto a codebase full of physical CSS. Build logical from the start, and adding an Arabic locale is a configuration change, not a rewrite.