RTL Design System Support: Right-to-Left Layout in React
Building RTL support into your React design system isn't optional if you're targeting Arabic, Hebrew, or Persian markets. Here's how to do it right.
Why RTL Is Harder Than Flipping Text Direction
Honestly, most developers think RTL support is just slapping dir="rtl" on the <html> tag and calling it a day. It's not. Not even close. What you're actually doing is rebuilding the spatial logic of your entire UI for a reading direction that flows from right to left — and that touches almost everything.
Arabic, Hebrew, Urdu, Persian — together these languages represent well over a billion potential users. If your component library treats RTL as an afterthought, you're shipping a broken product to those markets. The layout flips. The padding mirrors. The icon positions invert. Even your shadows need to change.
The good news is that modern CSS has most of what you need built in. Logical properties like margin-inline-start instead of margin-left, padding-inline-end instead of padding-right — these flip automatically based on the document direction. And Tailwind v4.0.2 ships with full logical property support out of the box. We're going to use all of it.
Setting Up the HTML dir Attribute and React Context
The foundation is the dir attribute. Set it on <html> for document-wide direction, or on any container element for a scoped subtree. In a Next.js app, you'd add this to your <html> tag in app/layout.tsx. For dynamic direction switching based on user locale, you need a React context that your components can read.
Here's a minimal RTL context setup that works with React 18 and Next.js 14+:
// contexts/DirectionContext.tsx
import { createContext, useContext, ReactNode } from 'react'
type Direction = 'ltr' | 'rtl'
const DirectionContext = createContext<Direction>('ltr')
export function DirectionProvider({
children,
dir = 'ltr',
}: {
children: ReactNode
dir: Direction
}) {
return (
<DirectionContext.Provider value={dir}>
<div dir={dir} className={dir === 'rtl' ? 'font-arabic' : ''}>
{children}
</div>
</DirectionContext.Provider>
)
}
export const useDirection = () => useContext(DirectionContext)Wrap your app in <DirectionProvider dir={locale === 'ar' ? 'rtl' : 'ltr'}> and every component can call useDirection() when it needs to make direction-aware decisions. This is cleaner than threading dir props everywhere.
Tailwind CSS Logical Properties for RTL Layouts
Before Tailwind v3.3, doing RTL in Tailwind meant using the rtl: variant prefix on every single utility. rtl:ml-4 ltr:mr-4. It was ugly and easy to miss. With logical properties now in stable Tailwind v4.0.2, you write ms-4 (margin-inline-start) instead of ml-4, and it flips automatically.
The mapping is straightforward once you internalize it. ps- and pe- replace pl- and pr- for padding. ms- and me- replace ml- and mr- for margin. start- and end- replace left- and right- for positioning. If you're building a component library from scratch, you should ban the directional utilities entirely and enforce logical ones via a lint rule.
There's a caveat though. Not everything maps cleanly. Box shadows are one example — shadow-md doesn't flip. If you have an elevated card with a shadow that sits 4px to the right and 2px down, in RTL you probably want it 4px to the left. You'll need custom shadow values per direction using the dir attribute in CSS: [dir='rtl'] .card { box-shadow: -4px 2px 8px rgba(0,0,0,0.15); }. That's the kind of detail that trips people up.
Your spacing system in CSS becomes much more important here. When you've already standardized on an 8px base grid with logical spacing tokens, RTL adaptation costs almost nothing. When you haven't, you're in for a painful audit.
Icons and Visual Assets That Must Flip
Here's the thing: not all icons should flip in RTL. This distinction matters. Directional icons — arrows, chevrons, back/forward buttons, progress indicators — should mirror. Semantic icons — a phone, a star, a heart, a logo — should not. Getting this wrong looks amateurish.
In SVG, flipping an icon is a CSS transform: transform: scaleX(-1). The question is where to apply it. You don't want to hardcode it into each icon component. A much better approach is to mark icons as directional in your icon system using a flip prop or a data-flip-rtl attribute, then let a CSS rule handle the transform:
/* globals.css */
[dir='rtl'] [data-flip-rtl='true'] {
transform: scaleX(-1);
}
/* Or in Tailwind with the rtl: variant for non-logical cases */
.icon-directional {
@apply rtl:-scale-x-100;
}The trickier cases are images and illustrations. A person looking left should look right in RTL layouts. You generally can't auto-flip these — you'll need RTL-specific assets or to exclude them from the mirroring logic entirely. Document this clearly in your design system so designers know what to provide.
Building RTL-Safe React Components
Every component you write should be RTL-aware by default, not as a patch. The most common mistakes happen in components that use absolute positioning, flex ordering, and text alignment. Let's look at a real example: a navigation item with an icon on the left and a label.
// components/NavItem.tsx
import { useDirection } from '@/contexts/DirectionContext'
type NavItemProps = {
icon: React.ReactNode
label: string
href: string
}
export function NavItem({ icon, label, href }: NavItemProps) {
// No manual direction check needed — logical flex handles it
return (
<a
href={href}
className="flex items-center gap-3 px-4 py-2.5
rounded-lg hover:bg-white/10
text-sm font-medium
ps-4 pe-6"
// gap-3 = 12px, ps-4 = 16px padding-inline-start
>
<span className="shrink-0" data-flip-rtl="true">
{icon}
</span>
<span>{label}</span>
</a>
)
}Notice what's happening here. We're using gap-3 (12px gap) instead of mr-3 on the icon. We're using ps-4 and pe-6 instead of pl-4 and pr-6. The flex direction already respects the document's dir attribute — in RTL, items flow right to left automatically. Zero direction-specific code in the component logic.
The useDirection() hook is reserved for cases where CSS alone can't do the job — like conditionally swapping an animation's translateX value, or choosing between two different data visualizations. Don't reach for it unless you have to.
Testing RTL Layouts Without Going Insane
How do you actually test this stuff? You can't manually switch your browser language every time. The fastest dev workflow is a keyboard shortcut or a toolbar toggle that swaps the dir attribute on the root element. Wire it to a query param: ?dir=rtl triggers RTL mode, ?dir=ltr (or no param) is default. You can pair this with your theme toggle so both direction and color mode are developer-toggleable.
For automated testing, Playwright is your friend. Write a test that navigates to each major page with ?dir=rtl, takes a screenshot, and compares it to a baseline. Visual regression catches the stuff unit tests never will — the icon that forgot to flip, the dropdown that's now off-screen, the tooltip positioned 200px off to the wrong side.
One underrated trick: temporarily add a CSS rule * { outline: 1px solid rgba(255,0,0,0.15) } while auditing RTL layouts. Every element gets a faint red outline, making it immediately obvious when padding or margins are wrong. Remove before committing, obviously.
Your Storybook setup should also have an RTL toolbar toggle. If you're not sure how to add one, check out the Storybook component library guide — there's a clean way to do it with the @storybook/addon-toolbars plugin that adds a direction selector to every story.
Typography Considerations for Arabic and Hebrew
Font selection changes completely for RTL languages. The font you're using for Latin characters almost certainly has poor or missing Arabic/Hebrew glyph support. You need a separate font stack for RTL content. Google Fonts has solid options — Noto Sans Arabic for broad coverage, Cairo for a modern geometric feel, Tajawal if you want something closer to a sans-serif system font.
Line height and letter spacing also work differently. Arabic script is cursive by nature, so letter-spacing adjustments that look great in English can break Arabic ligatures entirely. Keep letter-spacing: normal (or tracking-normal in Tailwind) for RTL text. Line height typically needs to be larger too — 1.8 is a common baseline for Arabic body text vs. 1.5 for Latin.
Don't forget numeric content. Arabic-Indic numerals (٠١٢٣٤٥٦٧٨٩) are culturally expected in some Arabic-speaking markets, while Western Arabic numerals (0123456789) are standard in others. This is usually a locale decision, not a direction decision — but it's worth documenting in your color system and design tokens documentation alongside other locale-specific overrides.
Shipping RTL Support in Your Design System
If you're building a component library that others will consume, RTL support needs to be a first-class citizen from day one — not something you bolt on in v2. That means your documentation shows RTL examples, your tokens use logical property names, and your components are tested in both directions before release.
A practical release checklist: every new component gets a dir="rtl" story in Storybook. Every utility class added to a component uses logical properties unless there's a documented reason not to. Icons get a flipRtl prop with a default that makes semantic sense. And the WCAG accessibility checks you're already running via your accessibility guide should include a direction switch — screen readers and keyboard navigation need to work in RTL too.
The effort upfront is real. But if you do it right the first time, RTL support costs almost nothing to maintain. It's the teams that skip it in v1 who end up with a rewrite on their hands when a client in Dubai asks why the entire UI is backwards.
FAQ
It flips CSS logical properties automatically — things like margin-inline-start, padding-inline-end, and flexbox flow. But physical properties like margin-left, padding-right, left, and right don't respond to dir at all. You have to migrate to logical properties or use the rtl: variant in Tailwind for anything using physical directions.
Logical properties first, always. They're more semantic and require zero duplication — you write one class and it works in both directions. The rtl: variant is a fallback for cases where logical properties don't exist yet, like box-shadow, transforms, or custom backgrounds. As of Tailwind v4.0.2, the logical property coverage is extensive enough that you'll rarely need rtl:.
Use the logical position utilities: start-0 instead of left-0, end-0 instead of right-0. In plain CSS, inset-inline-start and inset-inline-end work the same way. If you're stuck with a third-party component that uses left/right, you can override with [dir='rtl'] .component { left: auto; right: 0; } but it's not ideal.
Mirror icons that imply direction: arrows, chevrons, back/forward navigation, progress bars, sliders, and anything that represents flow or sequence. Don't mirror icons that are semantic objects: logos, phone icons, warning signs, hearts, stars, or any icon that represents a physical thing. When in doubt, check the W3C RTL icon guidance — they have a reference list.
Yes, but it takes a systematic audit. Start by enabling CSS logical properties for new components while leaving old ones alone. Then migrate old components one at a time, testing each in both directions. The highest-risk areas are components with absolute positioning, explicit left/right padding asymmetry, and icon placements. Set up visual regression tests before you start so you can catch regressions.
Yes, but you'll miss out on the automatic flipping from logical properties if you're writing physical property names in JS strings. Libraries like stylis-plugin-rtl or @emotion/css can post-process your styles to add RTL variants automatically. The cleanest solution long-term is still to use CSS logical properties directly — they work in any styling approach and require no plugins.