Building a Design System That Handles LTR and RTL Without Two Codebases
A builder's guide to architecting design tokens, component APIs, icon rules and testing so right-to-left support is structural, not a bolt-on theme.
A diagnostic checklist can tell you that a shipped page renders Arabic backwards. It cannot tell you why the same bug keeps coming back every time someone ships a new component. That second problem is architectural, and it lives one layer below the page: in the tokens, component APIs and icon sets that the whole product is built from.
If direction is something each component author has to remember, you will re-fix the same handful of bugs forever, because every new engineer starts from the same physical-CSS habits the last one had. If direction is a property of the system itself, resolved once in the token layer and the base components, then a contributor who has never thought about Arabic can still ship a feature that works in it. This is a guide to building the second kind of system: one where dir="rtl" is a supported state of every component from day one, not a theme bolted on after the English version shipped.
Key Takeaways
- CSS logical properties (
margin-inline-start,inset-inline-end,text-align: start) let you write layout rules once; the CSS Logical Properties and Values specification exists specifically so authors stop hand-mapping "left" to "start" themselves.- A design system's job is to make the correct default require zero extra thought from the person building the next component — not to document the correct default and hope people remember it.
- Component props should never say
leftorright.iconStart/iconEndandalign="start" | "end"survive a direction flip;iconLeft/iconRightdo not.- Icon mirroring is a per-icon design decision, not a CSS transform. Sequence and navigation icons flip; media, time and identity icons do not, because Spotify's engineering team found they encode tape or clock direction rather than reading direction.
- Bidi isolation belongs in a base text component, not in every feature team's markup. If isolating interpolated values is a step people can forget, they will forget it.
- Lint for physical properties the way you lint for unused variables. Tools like stylelint-use-logical turn "please use logical properties" from a code-review comment into a build failure.
Why a bolt-on RTL theme always breaks
The common pattern in startups that launch English-first and add Arabic later is a [dir="rtl"] stylesheet layered on top of the original one, flipping left to right selector by selector. It works for the components that exist on launch day. It fails silently for every component built afterward, because nothing in the system tells the next engineer that a new margin-left needs a matching override two files away. The RTL styles and the LTR styles drift, and the drift is invisible until a native Arabic speaker opens the page.
The fix is not a bigger override stylesheet. It is removing the need for one. CSS has had a direction-agnostic property model since the CSS Logical Properties and Values module, which the specification describes as giving "the author... the ability to control layout through logical, rather than physical, direction and dimension mappings." Once a component is written entirely in logical properties, dir="rtl" is not a second codebase to maintain — it's a value the browser already resolves correctly, because you never told it "left" in the first place.
Put direction in the token layer, not the component layer
Design tokens are the right place to make this decision exactly once. If your spacing, positioning and alignment tokens are physical, every component that consumes them inherits the bug. If they're logical, every component that consumes them inherits the fix.
MDN's guide to logical properties frames the underlying model as two dimensions rather than four directions: the inline dimension, which runs in whatever direction a line of text flows, and the block dimension, in which lines stack. In English and Arabic alike, block is vertical because both are horizontal writing systems; only the inline axis reverses. This is why margin-inline-start resolves to a different physical side depending on dir, while margin-block-start never does — the mapping is exactly the abstraction a token needs.
/* Physical tokens: correct for exactly one direction */
:root {
--card-padding-left: 1rem;
--card-padding-right: 1.5rem;
--icon-margin-right: 0.5rem;
}
/* Logical tokens: correct for both, because there is no "left" to get wrong */
:root {
--card-padding-inline-start: 1rem;
--card-padding-inline-end: 1.5rem;
--icon-margin-inline-end: 0.5rem;
}
.card {
padding-inline-start: var(--card-padding-inline-start);
padding-inline-end: var(--card-padding-inline-end);
}
Two things matter about doing this at the token layer rather than patching individual components. First, it's a one-time migration cost rather than a recurring one — you rename tokens once, and every component that references them is fixed at the same time. Second, it changes what "correct" looks like in a diff: a pull request that reintroduces margin-left inside a component that only consumes logical tokens is visibly wrong in review, whereas the same line is invisible in a codebase where physical and logical properties are mixed throughout.
The naming convention matters beyond CSS properties, too. Ahmad Shadeed's widely referenced RTL styling guide makes the case for renaming spatial class names like .header__left and .header__right to .header__start and .header__end for the same reason: a name that encodes a physical side has to be wrong in one direction, while a name that encodes a logical role never is. Apply the same rule to your BEM classes, your Tailwind-style utility names if you author your own, and your component prop names — which is the next layer up.
Component APIs should never say left or right
Tokens fix layout. They don't fix an API surface that leaks physical direction into every component that uses it. A button with an iconLeft prop forces every consumer to know, and re-decide, which side is correct for the current direction — exactly the manual step a system should remove.
// Leaks direction into every call site. Correct in English,
// silently wrong the day the page renders RTL.
<Button iconLeft={<ArrowIcon />}>Continue</Button>
// The component owns the direction decision once, internally.
<Button iconStart={<ArrowIcon />}>Continue</Button>
Inside the component, iconStart maps to margin-inline-end on the icon wrapper (the icon sits before the label in reading order, whichever edge "before" happens to be) and the consumer never has to think about it again. The same substitution applies everywhere a prop currently encodes a screen side: align="left" | "right" becomes align="start" | "end", dropdownPosition="right" becomes dropdownPosition="end", and so on. This is the component-level equivalent of the token renaming above, and it should happen at the same time — an iconStart prop that internally still writes margin-left has only moved the bug, not fixed it.
Icon and asset direction rules belong in the icon set, not in CSS
The single most common shortcut, and the one that causes the most visible damage, is a blanket rule that mirrors every SVG under [dir="rtl"]:
/* Flips everything, including the icons that must never flip */
[dir="rtl"] svg { transform: scaleX(-1); }
This ships fast because it fixes every arrow and chevron in the interface with one line. It also flips your play button, your checkmarks, and your clock face, because a CSS selector has no concept of what an icon means — only that it is an SVG inside an RTL container.
Whether an icon should mirror is a decision about what the icon represents, and it needs to be made once, by a designer, and encoded as metadata on the icon rather than re-derived from a stylesheet selector. The reasoning is well established: Material Design's archived bidirectionality guidance — still the clearest published breakdown, though it predates Material 3 and is marked archived — mirrors anything that expresses a reading-order sequence, such as back and forward arrows, breadcrumbs, pagination and progress indicators, while explicitly excluding icons whose direction is fixed by what they represent rather than by reading order. Spotify's engineering team reached a version of the same conclusion independently while shipping Arabic support: their write-up on the change notes that playback controls and the scrubber "do not require mirroring as they refer to the direction of tape being played," not the direction text is read in.
Build this into the icon set as data, not as a CSS rule that applies to every SVG by default:
{
"arrow-forward": { "mirrorRtl": true },
"chevron-right": { "mirrorRtl": true },
"play": { "mirrorRtl": false },
"clock": { "mirrorRtl": false },
"checkmark": { "mirrorRtl": false }
}
/* Opt in per icon, driven by the manifest above, never a blanket selector */
[dir="rtl"] [data-mirror-rtl="true"] {
transform: scaleX(-1);
}
This also gives you a place to record disagreement, because the convention is not universal even among products with real Arabic user bases. Shadeed's guide points out that WhatsApp mirrors its send icon in RTL while Facebook and Twitter's clients do not — there is no single correct answer for every glyph that resembles an arrow, which is exactly why the decision needs to be made explicitly, once, in your own icon manifest, rather than inherited implicitly from whatever a CSS transform happens to catch.
Give mixed-direction content one component, not one convention
Every product with Arabic content eventually renders a username, a price or an order number inside an Arabic sentence, and the Unicode Bidirectional Algorithm will reorder that content unpredictably unless it's isolated, because digits and Latin characters are weak or neutral relative to the strong-RTL Arabic around them. HTML has a text-level element built for exactly this case: <bdi>.
The fix, per MDN's documentation of the <bdi> element, is to wrap the inserted value so that "the directionality of text embedded in <bdi> does not influence the directionality of the surrounding text" and vice versa. Documenting "wrap interpolated values in <bdi>" in a style guide is a real fix and also a fragile one, because it depends on every contributor remembering it on every string, forever. The system-level version is to remove the choice: build a primitive text component that isolates by default, and require it — via lint rule or code review, not just documentation — anywhere a translated string carries a variable.
function BidiText({ children }: { children: React.ReactNode }) {
return <bdi>{children}</bdi>;
}
// Consumers never decide whether to isolate; the primitive already does
<p>{t('welcome_message', { username: <BidiText>{user.name}</BidiText> })}</p>
This is the same architectural move as the token and prop-naming changes above: take a decision that used to live in every individual contributor's head and move it into a primitive that ships with the system, so forgetting it stops being possible.
Test direction the way you test breakpoints
A design system that gets tested only in English will accumulate RTL regressions at the same rate it ships components, because nothing in a normal review catches a margin-right that only breaks a layout nobody looked at in Arabic. Three changes make this visible before it reaches production instead of after.
Lint physical properties out of new code. stylelint-use-logical enforces logical properties and values in CSS and flags physical ones — top, left, margin-left — as violations, converting a code-review reminder into a build failure. Turn it on for new component code even if you can't retrofit the whole codebase at once; every file it protects is one that can't regress.
{
"plugins": ["stylelint-use-logical"],
"rules": {
"csstools/use-logical": "always"
}
}
Render every component story in both directions. If your team uses Storybook, Ladle, or an equivalent component workshop, add a global direction toggle that flips dir on the preview root and re-renders the current story. This turns "does this look right in Arabic" from a manual audit performed occasionally into a one-click check performed on every component, every time.
Flag risky diffs for a second look. Spotify's team, while building out Arabic support, built a bot that flagged pull requests touching spacing, alignment or CSS transforms as ones that should be checked in RTL before merging — not a blocking gate, but a nudge at exactly the point where physical-property regressions are introduced. The categories worth flagging are narrow and stable: anything touching margin, padding, float, text-align, positioning, or transform is a candidate; most other changes are not.
None of these three catch every bug. Together they catch the two failure modes that actually recur: a physical property reintroduced in new code, and a layout nobody thought to check in the direction it wasn't built in.
Common mistakes
Writing the RTL override stylesheet instead of removing the need for one. An override file fixes what exists on the day it's written and drifts from every component built afterward. Logical properties in the token layer don't drift, because there's no second file to keep in sync.
Naming props after screen sides. iconLeft, align="right", .header__left all encode an assumption that has to be wrong in one direction. iconStart, align="end", .header__end don't.
Mirroring every SVG with one selector. It fixes twenty arrows and breaks your play button, your checkmarks and your logo in the same commit, because a CSS transform can't read what an icon means.
Documenting bidi isolation instead of building it into a component. A rule that lives only in a style guide gets forgotten at the same rate contributors turn over. A rule built into a base BidiText primitive can't be skipped by accident.
Testing RTL once, at launch. A system tested only when Arabic first ships will look correct on day one and wrong by the tenth component added afterward, because nothing caught the regressions in between.
What good looks like
- Spacing, position and alignment tokens are logical (
inline-start/inline-end,block-start/block-end), and no component references a physical token directly. - No component prop name contains
leftorright; direction-sensitive props usestart/end. - Icon mirroring is a boolean recorded per icon in the icon set's own data, not a CSS rule that applies to every SVG by default.
- Interpolated values inside translated strings pass through a base text primitive that isolates them, rather than relying on each contributor to remember
<bdi>. - A stylelint rule fails the build on new physical properties, even if older code hasn't been migrated yet.
- Every component in your workshop (Storybook or equivalent) renders correctly with a one-click direction toggle, in both directions, without a manual audit.
Getting this right at the system level doesn't just fix Arabic. It's also what makes the page-level checks in the companion diagnostic guide fast to run and boring to pass — a dir attribute that's already correct, bidi isolation that's already built into your text components, and icons that were already decided on purpose rather than flipped by accident, are exactly what a launch-day audit is checking for.