DEV Community

Mohammadreza Alamnejad
Mohammadreza Alamnejad

Posted on

RTL in practice: CSS logical properties, bidi text, icons, numerals and a testing checklist

The second layer of right-to-left work: the properties that don't flip, the strings that reorder themselves, and how to test so regressions don't come back.

Key takeaways

  • dir belongs in HTML, not CSS. Set dir on <html> (and on islands of other-direction content) so the browser's bidi algorithm, form controls and accessibility tree all agree.
  • Logical properties cover most layout, but not everything. transform, box-shadow, background-position, gradients and SVG coordinates are physical. Handle them with :dir().
  • Isolate every inserted value. <bdi>, dir="auto" and unicode-bidi: isolate in markup; FSI/PDI control characters in plain-text contexts like title, notifications and document.title.
  • Mirror icons by meaning, not by default. Arrows and "progress" icons flip; logos, checkmarks, media controls and clocks don't.
  • Format numerals with Intl, normalize input yourself, and avoid type="number" for codes and phone numbers.
  • Test RTL as a first-class configuration: lint for physical properties, run visual snapshots in both directions, and keep a bidi torture-string fixture.

I'm Mohammadreza Alamnejad, a UI/UX designer and developer, programmer and the founder of Arka Studio in Tehran. Everything my studio ships, including the café and restaurant platform NABZA, is Persian-first and right-to-left by default.

In an earlier article I covered the basics: lang="fa" dir="rtl" on the root, margin and padding logical properties, Jalali dates and digit normalization. This post is the follow-up for when the basics are done and the remaining bugs are the subtle ones.

1. Direction: HTML attribute first, CSS second

There are two ways to set direction: the dir attribute and the CSS direction property. Use the attribute.

<html lang="fa" dir="rtl">
  …
  <pre dir="ltr"><code>npm install</code></pre>
  <blockquote lang="en" dir="ltr">Quoted English paragraph…</blockquote>
</html>
Enter fullscreen mode Exit fullscreen mode

Why the attribute wins:

  • It's semantic: screen readers, the :dir() pseudo-class, form controls and the bidi algorithm all use it.
  • CSS direction can be lost when styles fail to load, and it doesn't carry meaning into the accessibility tree.
  • dir is inherited, so you only set it at boundaries: the root, code blocks, embedded content in another language, and user-generated content (dir="auto").

Also set lang together with dir on foreign-language islands. lang affects font selection, hyphenation, quotes and screen reader pronunciation; dir affects layout and bidi. They are different things and you usually need both.

2. Logical properties: the full map

Most teams start with margin-inline-start and stop. Here is the wider set worth adopting everywhere:

Physical Logical
margin-left / margin-right margin-inline-start / margin-inline-end
padding-left / padding-right padding-inline-start / padding-inline-end
left / right inset-inline-start / inset-inline-end
border-left border-inline-start
border-top-left-radius border-start-start-radius
border-top-right-radius border-start-end-radius
text-align: left text-align: start
float: left float: inline-start
clear: left clear: inline-start
width / height inline-size / block-size
overflow-x overflow-inline (support is still limited, keep overflow-x as fallback)

A component written fully logically:

.toast {
  position: fixed;
  inset-block-end: 1rem;
  inset-inline-end: 1rem;          /* bottom-left in RTL, bottom-right in LTR */
  padding-block: 0.75rem;
  padding-inline: 1rem 1.25rem;    /* start, end */
  border-inline-start: 4px solid var(--accent);
  border-start-end-radius: 12px;
  border-end-end-radius: 12px;
  text-align: start;
  max-inline-size: 28rem;
}
Enter fullscreen mode Exit fullscreen mode

Flexbox and grid already follow direction. flex-direction: row lays out from the inline start, so in RTL the first child is on the right. justify-content: flex-start and grid column lines follow too. A common bug is "fixing" this with flex-direction: row-reverse in RTL, which double-flips and puts things back in LTR order. If you find row-reverse in an RTL stylesheet, it's probably a bug.

Tailwind

Tailwind CSS (v3.3+) ships logical utilities: ms-*/me-* (margin start/end), ps-*/pe-* (padding), start-*/end-* (inset), rounded-s-*/rounded-e-*, border-s/border-e, and text-start/text-end. It also has rtl: and ltr: variants for the cases logical utilities can't express.

<button class="ps-4 pe-3 rounded-e-lg border-s-4 text-start">
  <svg class="rtl:-scale-x-100 …">…</svg>
  ادامه
</button>
Enter fullscreen mode Exit fullscreen mode

Rule of thumb for a code review: ml-, mr-, pl-, pr-, left-, right-, text-left, text-right in a component are red flags unless there's a comment explaining why.

3. What logical properties don't cover

These are physical, and they're where most remaining RTL bugs live.

Transforms. translateX(100%) moves right in both directions. Drawers, carousels, slide-in toasts and "swipe to reveal" all need direction-aware transforms.

.drawer          { transform: translateX(-100%); }  /* hidden off the left (LTR) */
.drawer:dir(rtl) { transform: translateX(100%); }   /* hidden off the right (RTL) */
.drawer[open]    { transform: none; }
Enter fullscreen mode Exit fullscreen mode

A trick that avoids duplicating every rule is a direction custom property:

:root            { --dir: 1; }
:root:dir(rtl)   { --dir: -1; }

.drawer          { transform: translateX(calc(-100% * var(--dir))); }
.chevron-next    { transform: scaleX(var(--dir)); }
Enter fullscreen mode Exit fullscreen mode

Shadows and gradients. box-shadow: 4px 0 8px … casts to the right in both directions. linear-gradient(to right, …) is physical too. Use :dir(rtl) overrides or the --dir multiplier (calc(4px * var(--dir))).

Background position. background-position: left 1rem center for an input icon won't move. Prefer an inline element positioned with inset-inline-start, or override in :dir(rtl).

SVG and canvas. Coordinates are always left-to-right. Charts are the big case: does time run right-to-left in your Persian dashboard? There's no universal answer. Many Persian-speaking users are used to left-to-right time axes from international products, while others expect RTL. Decide deliberately, document it, and keep axis labels and tooltips in RTL text either way.

JavaScript positioning. Popover and tooltip libraries computing left from getBoundingClientRect() must know the direction. Check getComputedStyle(el).direction or the closest [dir], and prefer libraries with RTL support built in. Also watch scrollLeft: in RTL, modern browsers report 0 at the starting (right) edge and negative values as you scroll left. Code written assuming positive values will break horizontal carousels.

const isRTL = getComputedStyle(scroller).direction === 'rtl';
const distanceFromStart = isRTL ? -scroller.scrollLeft : scroller.scrollLeft;
Enter fullscreen mode Exit fullscreen mode

4. Bidi text: why your punctuation jumps

The Unicode Bidirectional Algorithm (UAX #9) decides the visual order of characters with different directions. Strong characters (Persian letters, Latin letters) have a direction; neutral characters (spaces, punctuation, many symbols) take direction from their surroundings. Digits are "weak" and have their own rules. Problems appear when an LTR fragment sits at the edge of an RTL sentence, or when neutrals sit between the two.

Classic broken examples:

  • سفارش PO-1048 ثبت شد can render with the hyphen or the number in an unexpected position.
  • An English product name ending with ! or ) inside a Persian sentence: the punctuation attaches to the wrong side.
  • A phone number +98 912 … in a Persian paragraph shows the + at the wrong end.

In HTML: isolate

<p>سفارش <bdi>PO-1048</bdi> ثبت شد.</p>
<p>کاربر <bdi>{{ userName }}</bdi> نظر داد.</p>
<span dir="ltr">+98 912 000 0000</span>
<div dir="auto">{{ userComment }}</div>
Enter fullscreen mode Exit fullscreen mode
  • <bdi> isolates a value whose direction you don't know: user names, product names, IDs.
  • dir="ltr" on a span forces a value you know is LTR (phone, email, URL, code).
  • dir="auto" picks direction from the first strong character; ideal for comments, messages and search queries.
  • In CSS, unicode-bidi: isolate gives a component the same behaviour as <bdi>; unicode-bidi: plaintext makes each paragraph pick its own direction, which is useful in chat-like lists.

In plain text: FSI and PDI

Some strings never become DOM elements: title attributes, document.title, alert(), push and system notifications, <option> text in some environments, CSV exports. There you can't use <bdi>, but you can use the Unicode isolate control characters:

const FSI = '\u2068'; // First Strong Isolate
const PDI = '\u2069'; // Pop Directional Isolate
const LRI = '\u2066'; // Left-to-Right Isolate

export const isolate = (s) => `${FSI}${s}${PDI}`;
export const isolateLtr = (s) => `${LRI}${s}${PDI}`;

document.title = `${isolate(productName)} | نبضا`;
new Notification('سفارش جدید', { body: `میز ${isolateLtr(tableCode)} سفارش داد` });
Enter fullscreen mode Exit fullscreen mode

Prefer isolates (LRI, RLI, FSI + PDI) over the older embedding controls (LRE, RLE, PDF) and over sprinkling RLM/LRM marks. Isolates don't leak their effect into surrounding text. Strip these characters before comparing or storing values that will be searched, since they are invisible but real.

In i18n libraries

If you use ICU MessageFormat-style libraries, check whether interpolated arguments are isolated automatically. Some libraries can wrap arguments with isolates for you; others leave it to you. Either way, write one test with an LTR argument in an RTL message (see the torture strings below).

5. Icons: mirror by meaning

A short decision table I use with designers:

Mirror in RTL Don't mirror
Back / forward arrows, chevrons Logos and brand marks
Breadcrumb separators Checkmark, plus, close (×)
"Next step", "send" when it implies direction Play, pause, fast-forward (media follows tape direction)
Text alignment icons, list indent/outdent Clocks and most refresh/sync circles
Progress or "reading" metaphors (book, open door) Photos, maps, real objects
Sidebar toggle, drawer open Numbers and letters inside icons

A few icons have no universal answer (undo/redo, the search magnifier, a phone handset). Design systems differ on them, so decide once, write it down, and apply it everywhere.

Implementation:

[dir="rtl"] .icon--directional { transform: scaleX(-1); }
/* or */
.icon--directional:dir(rtl)    { transform: scaleX(-1); }
Enter fullscreen mode Exit fullscreen mode

Mark icons as directional in your icon component's API (<Icon name="chevron" directional />) instead of adding CSS at each call site. That keeps the decision in the design system, where it belongs. And if an icon contains text or numbers, never mirror it; ship a separate RTL variant.

6. Numerals: display, input and alignment

Display with Intl

const nf = new Intl.NumberFormat('fa-IR');
nf.format(48250000);             // Persian digits with Persian grouping
new Intl.NumberFormat('fa-IR', { style: 'percent' }).format(0.25);
new Intl.DateTimeFormat('fa-IR-u-ca-persian', { dateStyle: 'medium', timeZone: 'Asia/Tehran' })
  .format(new Date());
Enter fullscreen mode Exit fullscreen mode

Use Intl instead of hand-written digit replacement for display. It handles separators, percent placement and calendars consistently. Use digit replacement only for normalization of input (below).

Input: avoid type="number" for codes

<input type="number"> is meant for quantities. For phone numbers, OTPs, postal codes and card numbers, it causes problems: leading zeros, spin buttons, scroll-wheel changes, and inconsistent handling of non-Latin digits across browsers and keyboards. Use text inputs with the right keyboard hint:

<label for="otp">کد تأیید</label>
<input id="otp" type="text" inputmode="numeric" autocomplete="one-time-code"
       dir="ltr" maxlength="6" pattern="[0-9۰-۹٠-٩]*">

<label for="mobile">شماره موبایل</label>
<input id="mobile" type="tel" inputmode="tel" autocomplete="tel" dir="ltr">
Enter fullscreen mode Exit fullscreen mode

Then normalize before validation, in one shared function used by every form and by the server:

const PERSIAN = '۰۱۲۳۴۵۶۷۸۹';
const ARABIC  = '٠١٢٣٤٥٦٧٨٩';

export function normalizeDigits(s: string): string {
  return s.replace(/[۰-۹٠-٩]/g, (d) => {
    const i = PERSIAN.indexOf(d);
    return String(i >= 0 ? i : ARABIC.indexOf(d));
  });
}

export function normalizeMobile(raw: string): string | null {
  const s = normalizeDigits(raw).replace(/[\s\-()]/g, '');
  const m = s.match(/^(?:\+98|0098|98|0)?(9\d{9})$/);
  return m ? `0${m[1]}` : null;   // canonical 11-digit form
}
Enter fullscreen mode Exit fullscreen mode

Alignment

In tables, align numeric columns to the end of the cell and use tabular figures, so digits of the same place value line up and amounts are easy to compare:

.table td.num { font-variant-numeric: tabular-nums; text-align: end; }
Enter fullscreen mode Exit fullscreen mode

Check that your Persian font actually supports tnum; some don't, and you'll need a font that does for financial tables.

7. Forms, focus and caret details

  • Inputs inherit direction. A Persian form has RTL inputs by default. Email, URL, phone, code and password fields should be dir="ltr" so the caret and selection behave predictably; their labels stay RTL.
  • Search boxes should use dir="auto" because users search in both scripts.
  • Placeholders follow the input's direction. An LTR input with a Persian placeholder looks odd; consider ::placeholder { direction: rtl; text-align: end; } or put the hint in a visible help text instead.
  • Focus order follows DOM order, not visual order. Don't reorder elements visually with order or absolute positioning if it breaks keyboard navigation.
  • Select and date-picker popovers must open aligned to the inline start of the field.

8. Testing: make RTL a first-class configuration

Lint

Use stylelint-use-logical (from the csstools project) to flag physical properties in CSS, and an ESLint rule or code-review check for physical Tailwind classes.

{
  "plugins": ["stylelint-use-logical"],
  "rules": { "csstools/use-logical": ["always", { "except": ["float"] }] }
}
Enter fullscreen mode Exit fullscreen mode

Visual snapshots in both directions

With Playwright, run key pages and components in both directions and compare screenshots:

// rtl.spec.ts
import { test, expect } from '@playwright/test';

for (const dir of ['rtl', 'ltr'] as const) {
  test(`checkout layout (${dir})`, async ({ page }) => {
    await page.goto('/checkout');
    await page.evaluate((d) => document.documentElement.setAttribute('dir', d), dir);
    await expect(page).toHaveScreenshot(`checkout-${dir}.png`, { fullPage: true });
  });
}
Enter fullscreen mode Exit fullscreen mode

Even if you ship RTL only, the LTR snapshot is useful: a layout that breaks when direction flips usually has hidden physical properties.

A bidi torture-string fixture

Keep a fixture of strings and render them in every text-bearing component in Storybook or a test page:

export const bidiFixtures = [
  'سفارش PO-1048 ثبت شد.',
  'محصول iPhone 15 Pro (نسخهٔ ۲۵۶) موجود است!',
  'تماس: +98 912 000 0000',
  'ایمیل: someone@example.com را تأیید کنید.',
  'Order #۱۲۳ was paid',            // LTR sentence with Persian digits
  'می‌شود',                          // ZWNJ must survive
  '۲۵٪ تخفیف تا ۱۵ مهر',
  'نام خیلی خیلی خیلی طولانی محصول که باید در دو خط بشکند و دکمه را خراب نکند',
];
Enter fullscreen mode Exit fullscreen mode

Manual checks before release

Automated tests catch regressions; a short manual pass catches meaning. On a real phone:

  • [ ] <html lang dir> correct; LTR islands marked
  • [ ] No physical properties in components (lint passes)
  • [ ] Drawers, toasts, carousels slide from the correct side
  • [ ] Shadows, gradients and input icons sit on the correct side
  • [ ] Horizontal scrollers start at the inline start; scrollLeft logic handles negatives
  • [ ] Directional icons mirrored; logos, media and checkmarks not
  • [ ] IDs, names, phones and emails isolated in sentences, titles and notifications
  • [ ] Numbers formatted with Intl; inputs accept and normalize any digits
  • [ ] Code, phone, email and OTP fields are dir="ltr" with RTL labels
  • [ ] Tables use tabular numerals and end alignment
  • [ ] Focus order matches reading order
  • [ ] Bidi fixture renders correctly in every text component
  • [ ] Charts have a documented time-axis direction

Closing

The first 80% of RTL is easy and well documented. The last 20% is a list of exceptions: physical transforms, invisible bidi rules, icons with meaning, and numbers that are both text and data. Write those exceptions down once, encode them in your design system and lint rules, and test them like any other configuration. Then RTL stops being a recurring bug category.


About the author

Mohammadreza Alamnejad is a UI/UX designer and developer, programmer and network security professional, and the founder of Arka Studio, a design and development studio in Tehran. He built NABZA, a Persian-first café and restaurant operations platform, along with Avenya, Natureio and Radepa. More on his founder page at Arka Studio.

Top comments (0)