Build design system components that work correctly in right-to-left languages like Arabic and Hebrew using CSS logical properties, bidirectional text handling, and automated RTL testing.
Build design system components that work correctly in right-to-left languages like Arabic and Hebrew using CSS logical properties, bidirectional text handling, and automated RTL testing.
Physical CSS properties (left, right, margin-left, padding-right, border-left) are direction-specific and break RTL layouts. CSS logical properties adapt automatically based on the document writing direction:
1/* Before: physical properties — breaks in RTL */
2.card {
3 margin-left: 1rem;
4 padding-right: 1.5rem;
5 border-left: 4px solid var(--color-interactive-primary);
6 text-align: left;
7}
8
9/* After: logical properties — works in both LTR and RTL */
10.card {
11 margin-inline-start: 1rem; /* left in LTR, right in RTL */
12 padding-inline-end: 1.5rem; /* right in LTR, left in RTL */
13 border-inline-start: 4px solid var(--color-interactive-primary);
14 text-align: start; /* left in LTR, right in RTL */
15}
16
17/* Block direction (top/bottom) — usually unchanged */
18.card {
19 padding-block: 1rem; /* top and bottom */
20 margin-block-start: 0.5rem; /* top in both directions */
21 border-block-end: 1px solid var(--color-border-default);
22}
23
24/* Directional layout on the root */
25:root[dir='rtl'] {
26 font-family: 'Noto Sans Arabic', 'Inter', sans-serif;
27}Apply dir='rtl' to the html element (or a subtree) and all logical properties flip automatically with no CSS changes required.
Pages displaying mixed LTR/RTL content (an Arabic sentence containing an English product name) require explicit bidi control. The HTML dir attribute and unicode-bidi CSS property manage this:
1<!-- Correct: mark RTL content explicitly -->
2<html lang="ar" dir="rtl">
3 <body>
4 <!-- Arabic text flows right to left -->
5 <p>مرحبا بك في <bdi>DeveloperBootstrap</bdi></p>
6 <!-- bdi: isolates embedded text, prevents bleeding -->
7
8 <!-- Inline LTR in RTL context -->
9 <p>السعر: <span dir="ltr">$49.99</span></p>
10 </body>
11</html>1/* Component handling mixed content */
2.product-price {
3 /* Prices are always LTR regardless of page direction */
4 direction: ltr;
5 unicode-bidi: isolate;
6 font-variant-numeric: tabular-nums;
7}
8
9.user-content {
10 /* Let browser determine direction from content */
11 unicode-bidi: plaintext;
12}Use <bdi> for user-generated content where direction is unknown — it isolates the element from the surrounding bidi context to prevent display corruption.
Catch RTL layout issues in Storybook before they reach production:
1// .storybook/preview.tsx
2import type { Decorator } from '@storybook/react';
3
4const withDirection: Decorator = (Story, context) => {
5 const dir = context.globals.direction ?? 'ltr';
6 return (
7 <div
8 dir={dir}
9 lang={dir === 'rtl' ? 'ar' : 'en'}
10 style={{
11 textAlign: 'start',
12 fontFamily:
13 dir === 'rtl'
14 ? "'Noto Sans Arabic', 'Inter', sans-serif"
15 : "'Inter', sans-serif",
16 }}
17 >
18 <Story />
19 </div>
20 );
21};
22
23export const decorators = [withDirection];
24
25export const globalTypes = {
26 direction: {
27 name: 'Direction',
28 defaultValue: 'ltr',
29 toolbar: {
30 icon: 'paragraph',
31 items: [
32 { value: 'ltr', title: 'LTR (Left to Right)' },
33 { value: 'rtl', title: 'RTL (Right to Left)' },
34 ],
35 },
36 },
37};Not all icons should mirror in RTL. Icons that represent directional navigation (chevrons, arrows, back/forward) must mirror. Icons that represent physical objects (settings gear, heart, shopping cart) or logos must not mirror. Document this decision per icon:
1// packages/icons/src/Icon.tsx
2interface IconProps {
3 name: string;
4 size?: number;
5 /** Set to false for icons that must NOT mirror in RTL (logos, objects) */
6 mirrorInRtl?: boolean;
7}
8
9export function Icon({ name, size = 24, mirrorInRtl = true }: IconProps) {
10 return (
11 <svg
12 width={size}
13 height={size}
14 aria-hidden="true"
15 style={{
16 // CSS logical: flip transform origin for RTL mirroring
17 transform: mirrorInRtl ? undefined : 'none',
18 }}
19 className={mirrorInRtl ? 'icon icon--mirror-rtl' : 'icon'}
20 >
21 <use href={`#icon-${name}`} />
22 </svg>
23 );
24}1[dir='rtl'] .icon--mirror-rtl {
2 transform: scaleX(-1);
3}Maintain a documented list of mirror=false icons in the design system icon catalog in Storybook, so designers and engineers apply the rule consistently.
Manual RTL QA does not scale. Add automated visual regression for RTL layouts using Playwright and the existing Storybook build:
1// tests/rtl-visual.spec.ts
2import { test, expect } from '@playwright/test';
3
4const COMPONENTS_TO_TEST = [
5 'components-button--primary',
6 'components-card--default',
7 'components-form--login',
8 'components-navigation--sidebar',
9];
10
11for (const story of COMPONENTS_TO_TEST) {
12 test(`RTL layout: ${story}`, async ({ page }) => {
13 // Storybook iframe URL with RTL globals
14 const url =
15 `http://localhost:6006/iframe.html?id=${story}` +
16 `&globals=direction:rtl`;
17 await page.goto(url);
18 await page.waitForLoadState('networkidle');
19 await expect(page).toHaveScreenshot(`rtl-${story}.png`, {
20 threshold: 0.01,
21 maxDiffPixelRatio: 0.02,
22 });
23 });
24}Run this in CI on every PR that touches CSS files. Store screenshots in Git LFS or Chromatic so diffs are reviewable. Alert specifically on any component that was previously RTL-verified but now shows a pixel diff above threshold.
Audit your top 10 most-used components for physical CSS properties and migrate them to logical properties. Add the RTL Storybook decorator and run a manual RTL check on your navigation component today.
Your progress is saved in this browser, on this device only.