---
name: rtl-ui
description: Build and review right-to-left (RTL) Persian web interfaces - html lang/dir setup, CSS logical properties instead of left/right, flex/grid behavior in RTL, which icons to mirror and which not, bidi isolation for mixed Persian/English/numbers (bdi, dir=auto, unicode-bidi), LTR islands for phone numbers, code and URLs, self-hosted Persian fonts (Vazirmatn, Sahel), Persian typography rules (line-height, no letter-spacing, no uppercase), Persian digit display and digit normalization in inputs, Tailwind rtl/ltr variants and logical utilities, and an RTL test checklist. Use when creating or fixing any Persian/Farsi (or other RTL) UI, converting an LTR layout to RTL, or when the user mentions RTL, راست‌چین, dir="rtl", Vazirmatn or garbled mixed-direction text. رابط کاربری راست‌به‌چپ فارسی
---

# رابط کاربری راست‌به‌چپ (RTL Persian UI)

## 1. Document setup

```html
<html lang="fa" dir="rtl">
```

- Put `dir` in markup on the root element, not only CSS `direction`. The attribute drives the bidi algorithm, form controls, `:dir()` and accessibility; `lang="fa"` lets screen readers pick a Persian voice.
- Multilingual app: set both from the active locale (`document.documentElement.lang = 'fa'; document.documentElement.dir = 'rtl'`).
- Mark LTR islands locally with `dir="ltr"` (section 5).

## 2. Layout with logical properties

Write direction-agnostic CSS so one stylesheet serves RTL and LTR.

| Physical (avoid) | Logical (use) |
|---|---|
| `margin-left` / `margin-right` | `margin-inline-start` / `margin-inline-end` |
| `padding-left` / `padding-right` | `padding-inline-start` / `padding-inline-end` |
| `left: 0` / `right: 0` | `inset-inline-start: 0` / `inset-inline-end: 0` |
| `text-align: left` / `right` | `text-align: start` / `end` |
| `border-left` | `border-inline-start` |
| `border-top-left-radius` | `border-start-start-radius` |

- Flexbox (`flex-direction: row`) and Grid columns already follow `dir`: in RTL the first item is on the right. Do **not** add `row-reverse` for RTL; that flips it back.
- `justify-content: flex-start` / `start` follow the direction; the keywords `left` / `right` do not.
- Things that do **not** flip automatically: `transform: translateX()`, `background-position`, `box-shadow` offsets, gradients with `to left/right`, absolutely positioned SVG, canvas drawing, animation keyframes. Override them with `:dir(rtl)` (Baseline widely available since December 2023):

```css
.drawer { transform: translateX(-100%); }
.drawer:dir(rtl) { transform: translateX(100%); }
```

- Horizontal scrollers: in RTL, `scrollLeft` is `0` at the start (rightmost position) and becomes increasingly negative as the user scrolls toward the end. Carousel code that assumes positive values breaks.

## 3. Icons: what to mirror

Mirror (they express direction, motion or reading order):
- Back / forward arrows, next / previous chevrons, breadcrumb separators, "open submenu" carets. In RTL, a back button points right.
- Progress bars, sliders, steppers (and the order of the numbers along them).
- Icons that represent lines of text or list alignment.
- Icons showing forward motion, for example a speaker with sound waves.

Do **not** mirror:
- Media playback controls (play, pause, fast-forward, rewind).
- Checkmarks, close (X), and other universal or direction-neutral symbols.
- Clocks and other real-world objects; logos (never flip a logo); code icons such as `</>`.
- The slash in "disabled / prohibited" variants stays the same.

Flip only icons you mark as directional; never flip all icons globally:

```css
.icon-directional:dir(rtl) { transform: scaleX(-1); }
```

## 4. Bidi: mixed Persian, English and numbers

Typical bugs: a trailing `!` or `.` jumps to the wrong side, `React 19` or an English user name reorders the words around it, a phone number shows up reversed in groups.

- Text injected at run time whose direction you cannot predict (user names, product titles, search terms): wrap it in `<bdi>`. It isolates the text and picks a direction from its first strong character.

```html
<p>کاربر <bdi>{{ username }}</bdi> ۳ دیدگاه نوشت.</p>
```

- Blocks of user-generated content (comments, posts, chat messages) and free-text inputs and textareas: `dir="auto"`.
- Any element with a `dir` attribute is isolated from surrounding text. In CSS, use `unicode-bidi: isolate` on inline badges or chips, and `unicode-bidi: plaintext` on blocks where each paragraph should pick its own direction.
- Prefer markup over Unicode control characters. Use the isolate characters FSI U+2068 ... PDI U+2069 (or LRM U+200E / RLM U+200F) only where markup is impossible: `<title>`, attribute values (`title`, `placeholder`, `aria-label`), `<option>` text, notifications, SMS. In code, write them as escapes (`\u2068`, `\u2069`), never as invisible literals.

## 5. LTR islands

These always stay LTR, and the digit order inside a number never reverses:
- Phone and card numbers, IBAN (شبا), tracking codes, OTP codes.
- Emails, URLs, file paths, code snippets, version strings, keyboard shortcuts.

```html
<span dir="ltr">0912 345 6789</span>
<input type="tel" dir="ltr" inputmode="tel" autocomplete="tel">
<input type="email" dir="ltr">
<pre dir="ltr"><code>npm install vazirmatn</code></pre>
```

A Persian placeholder in an LTR input aligns left. Optional fix:

```css
input[dir="ltr"]:placeholder-shown { direction: rtl; }
```

## 6. Persian fonts

| Font | License | Notes |
|---|---|---|
| Vazirmatn | SIL OFL 1.1 | First choice. npm `vazirmatn`, or Fontsource `@fontsource-variable/vazirmatn`. Variable font; extra builds in the repo (UI, Farsi-Digits, Non-Latin) |
| Sahel | SIL OFL 1.1 | rastikerdar/sahel-font |
| Shabnam | SIL OFL 1.1 | Repository archived (discontinued); prefer Vazirmatn for new work |

- **Self-host the font files.** `fonts.googleapis.com` has been slow or disrupted from inside Iran (Iranian hosts reported ISP disruption starting 1401) and a failed font request falls back silently. jsDelivr and other foreign CDNs carry the same risk; serve `woff2` from your own origin or a domestic CDN.

```css
@font-face {
  font-family: Vazirmatn;
  src: url('/fonts/Vazirmatn[wght].woff2') format('woff2');
  font-weight: 100 900;
  font-display: swap;
}
:root { font-family: Vazirmatn, Tahoma, sans-serif; }
```

- Preload the main weight on the critical path (`<link rel="preload" as="font" type="font/woff2" crossorigin>`).
- "Farsi-Digits" font builds draw Latin `0-9` with Persian shapes, but copy-paste still yields Latin digits. Prefer real Persian digits in the text (section 8) and a standard build.

## 7. Typography

- **line-height**: Persian has dots and marks above and below the letters; it needs more leading than Latin. Start around 1.8 for body text and tune by eye.
- **letter-spacing: normal** for Persian. CSS Text 3 forbids letter-spacing from breaking the joins of cursive scripts, but browsers differ: recent Chrome and Firefox skip spacing between joined letters, while WebKit (Safari) has been reported to space every glyph, and the words fall apart. Scope tracking to Latin only, for example `:lang(en)`.
- **No `text-transform: uppercase/capitalize` and no small-caps.** Persian has no letter case, so these do nothing to Persian and only distort embedded Latin (brand names, acronyms). Remove them from RTL styles.
- Persian next to uppercase Latin at the same size can look small; a slightly larger Persian size can balance it.
- Vazirmatn has no italic; browsers fake one by slanting the glyphs. Use bold for emphasis.

## 8. Numbers

- Display: `(1234567.5).toLocaleString('fa-IR')` returns `۱٬۲۳۴٬۵۶۷٫۵` (Persian digits, ٬ thousands, ٫ decimal). `(0.5).toLocaleString('fa-IR', {style: 'percent'})` returns `۵۰٪`.
- Toman is not an ISO 4217 currency (IRR, Rial, is). Most shops show Toman: format the number and append the unit yourself, `${n.toLocaleString('fa-IR')} تومان`.
- Keep Latin digits for values the user copies into another system (card numbers, IBAN, tracking codes), and in code, URLs and data sent to APIs.

## 9. Input handling

Users type Persian digits (Persian keyboard) or Arabic-Indic digits (Arabic keyboards, some Android keyboards). Normalize to ASCII **before** validation and storage.

- JS: `/\d/` matches only ASCII digits and `Number('۱۲۳')` is `NaN`, so unnormalized input fails validation.
- Python: `\d` matches Persian digits and `int('۱۲۳')` returns `123`, so unnormalized input passes validation and leaks into storage. Normalize anyway, or use `re.ASCII`.

```js
// Persian (U+06F0-06F9) and Arabic-Indic (U+0660-0669) digits -> ASCII
export const toEnDigits = (s) =>
  s.replace(/[\u06F0-\u06F9\u0660-\u0669]/g, (d) => String(d.charCodeAt(0) & 0xf));

const mobile = toEnDigits(input.value.trim());
/^(?:\+98|0098|0)?9\d{9}$/.test(mobile); // Iranian mobile: 09xxxxxxxxx, +989xxxxxxxxx
```

- Use `type="text"` with `inputmode="numeric"` (or `"tel"`) for numeric fields, so you can normalize before validating.
- Search: normalize Arabic ي/ك to Persian ی/ک (see the persian-writing skill), and treat ZWNJ, space and nothing as equivalent when matching (`می‌شود`, `می شود`, `میشود`).
- Never strip ZWNJ (U+200C) from names or text fields; it is part of correct spelling.

## 10. Tailwind CSS

- Prefer logical utilities: `ms-*` / `me-*`, `ps-*` / `pe-*`, `border-s` / `border-e`, `rounded-s-*` / `rounded-e-*`, `text-start` / `text-end`. For inset, current docs use `inset-s-*` / `inset-e-*`; Tailwind v3 uses `start-*` / `end-*`.
- Use `gap-*` instead of `space-x-*` for horizontal spacing; gap does not depend on direction.
- Use the `rtl:` / `ltr:` variants only for what logical utilities cannot express, for example flipping a directional icon: `rtl:-scale-x-100`.
- Replace `ml-*`, `mr-*`, `pl-*`, `pr-*`, `left-*`, `right-*`, `text-left`, `text-right` in RTL-capable code.

## 11. RTL test checklist

- [ ] `<html lang="fa" dir="rtl">` is set; switching the root `dir` mirrors every page (nav, forms, tables, modals, toasts, drawers, carousels, breadcrumbs, pagination).
- [ ] No physical properties left. Search, for example: `rg -n "(margin|padding|border)-(left|right)|text-align:\s*(left|right)|\b(left|right):" src/`
- [ ] Directional icons are mirrored; media controls, checkmarks and logos are not.
- [ ] Mixed strings render correctly: `نسخهٔ React 19 منتشر شد!`, an English user name inside a Persian sentence, `۵ GB`, a URL at the end of a sentence followed by a period.
- [ ] Phone numbers, codes and emails display LTR; their inputs are `dir="ltr"`.
- [ ] Inputs accept Persian and Arabic-Indic digits and store ASCII; ZWNJ survives saving.
- [ ] Fonts still load with `fonts.googleapis.com` blocked (DevTools request blocking).
- [ ] No `letter-spacing` or `text-transform` on Persian text; checked in Safari as well.
- [ ] Screen reader reads Persian content with a Persian voice (`lang="fa"`).
