# Logical Properties for Direction

**MUST** · **ID:** `layout-logical-properties` · **Category:** layout
**Source:** [jakubkrehel](https://github.com/jakubkrehel/skills)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/layout-logical-properties

> MUST: Use direction-agnostic properties so the layout mirrors in RTL: `margin-inline-start`/`-end` not `margin-left`/`-right`, `padding-inline-end` not `padding-right`, `inset-inline-start`/`-end` not `left`/`right`, `text-align: start`/`end` not `left`/`right` (Tailwind: `ms-*`, `me-*`, `ps-*`, `pe-*`, `start-*`, `end-*`, `text-start`, `text-end`). Set `lang` — it picks quotes, hyphenation, font fallback and the screen-reader voice — and `dir="rtl"` where needed. Under `dir="rtl"` the browser mirrors flex/grid order while every hardcoded left and right stays put, so icons detach and padding collides.

Use direction-agnostic CSS — margin-inline-start, not margin-left — so a layout mirrors correctly in right-to-left languages

Physical properties encode a hardcoded assumption that text flows left-to-right. `margin-left` means "the left edge of the screen"; `margin-inline-start` means "the edge the reading starts at", which is the left in English and the right in Arabic, Hebrew, Persian and Urdu. Set `dir="rtl"` on a subtree built from physical properties and the browser dutifully mirrors the flex/grid order while every hardcoded left and right stays put — so the icon that hugged the text now detaches from it, the padding that kept a dismiss button clear of the label is now on the wrong side and the two collide, and the copy is still ragged-left in a language that is read right-to-left. The full mapping is small: `margin-inline-start`/`-end` for `margin-left`/`-right`, `padding-inline-start`/`-end`, `inset-inline-start`/`-end` for `left`/`right`, `border-inline-start`, and `text-align: start`/`end` for `left`/`right`. Tailwind ships all of them (`ms-*`, `me-*`, `ps-*`, `pe-*`, `start-*`, `end-*`, `text-start`, `text-end`), so the direction-agnostic version costs the same number of characters as the broken one. `lang` is the other half and is not decorative: it selects the right quotation marks, hyphenation dictionary and font fallbacks, and it is what a screen reader uses to pick a voice. Note this is a distinct gap from the internationalisation rules the guide already covers — content-language-detection and content-translate-no are about the words; this is about which way the box points.

## Rule snippet

```tsx
<html lang="ar" dir="rtl">
/* .card { margin-left: 1rem; text-align: left; }  ✗ */
.card { margin-inline-start: 1rem; text-align: start; } /* ✓ */
```

## Bad — do not do this

`layout-logical-properties-bad`

```tsx
import { useState } from 'react';
import { Alert02Icon, Cancel01Icon } from '@hugeicons/core-free-icons';
import { HugeiconsIcon } from '@hugeicons/react';

export function LogicalPropertiesBad() {
  const [rtl, setRtl] = useState(true);

  return (
    <div className="space-y-3">
      <div className="flex items-center justify-between gap-3">
        <p className="text-xs text-muted-foreground">Physical properties: ml / text-left / pr</p>
        <button
          type="button"
          onClick={() => setRtl((v) => !v)}
          aria-pressed={rtl}
          className="rounded-md border border-border bg-muted px-3 py-1.5 text-xs font-medium text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
        >
          dir: {rtl ? 'rtl' : 'ltr'}
        </button>
      </div>

      {/* No `lang`, so the browser has no idea which quotes or hyphenation to use either. */}
      <div dir={rtl ? 'rtl' : 'ltr'}>
        <div className="relative flex items-center rounded-lg border border-border bg-card p-3 pr-12">
          <HugeiconsIcon
            icon={Alert02Icon}
            size={20}
            className="shrink-0 text-destructive"
            aria-hidden
          />
          {/* ml-3 = "3 units from the LEFT of the screen", not "from the icon". */}
          <div className="ml-3 min-w-0 text-left">
            <p className="truncate text-sm font-medium text-foreground">
              {rtl ? 'فشل النشر إلى الإنتاج' : 'Deploy to production failed'}
            </p>
            <p className="truncate text-xs text-muted-foreground">
              {rtl ? 'منذ دقيقتين · الفرع main' : '2 minutes ago · branch main'}
            </p>
          </div>
          {/* Pinned to the right, whatever the reading direction. */}
          <button
            type="button"
            aria-label="Dismiss"
            className="absolute right-3 top-1/2 -translate-y-1/2 rounded-md p-1 text-muted-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
          >
            <HugeiconsIcon icon={Cancel01Icon} size={16} aria-hidden />
          </button>
        </div>
      </div>

      <p className="text-xs text-destructive">
        Flip to RTL. The flex order mirrors, but every hardcoded left and right stays put: the{' '}
        <code>ml-3</code> gap is now on the far side of the text so the icon detaches from the words it
        belongs to, the <code>pr-12</code> reserve is stranded on the wrong edge and the dismiss button —
        pinned with <code>right-3</code> — lands on top of the icon, and the copy is still ragged-left in
        a language that is read right-to-left.
      </p>
    </div>
  );
}
```

## Good — do this

`layout-logical-properties-good`

```tsx
import { useState } from 'react';
import { Alert02Icon, Cancel01Icon } from '@hugeicons/core-free-icons';
import { HugeiconsIcon } from '@hugeicons/react';

export function LogicalPropertiesGood() {
  const [rtl, setRtl] = useState(true);

  return (
    <div className="space-y-3">
      <div className="flex items-center justify-between gap-3">
        <p className="text-xs text-muted-foreground">Logical properties: ms / text-start / pe</p>
        <button
          type="button"
          onClick={() => setRtl((v) => !v)}
          aria-pressed={rtl}
          className="rounded-md border border-border bg-muted px-3 py-1.5 text-xs font-medium text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
        >
          dir: {rtl ? 'rtl' : 'ltr'}
        </button>
      </div>

      {/* `lang` is not decorative: it picks the quotes, the hyphenation dictionary,
          the font fallbacks, and the voice a screen reader reads this in. */}
      <div dir={rtl ? 'rtl' : 'ltr'} lang={rtl ? 'ar' : 'en'}>
        <div className="relative flex items-center rounded-lg border border-border bg-card p-3 pe-12">
          <HugeiconsIcon
            icon={Alert02Icon}
            size={20}
            className="shrink-0 text-destructive"
            aria-hidden
          />
          {/* ms-3 = "3 units from where the reading starts", so the gap stays next to the icon. */}
          <div className="ms-3 min-w-0 text-start">
            <p className="truncate text-sm font-medium text-foreground">
              {rtl ? 'فشل النشر إلى الإنتاج' : 'Deploy to production failed'}
            </p>
            <p className="truncate text-xs text-muted-foreground">
              {rtl ? 'منذ دقيقتين · الفرع main' : '2 minutes ago · branch main'}
            </p>
          </div>
          <button
            type="button"
            aria-label="Dismiss"
            className="absolute end-3 top-1/2 -translate-y-1/2 rounded-md p-1 text-muted-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
          >
            <HugeiconsIcon icon={Cancel01Icon} size={16} aria-hidden />
          </button>
        </div>
      </div>

      <p className="text-xs text-success">
        The same row mirrors correctly with zero extra CSS and no RTL stylesheet, because nothing in it
        names a physical edge: <code>ms-3</code> is a gap from the start edge, <code>pe-12</code> reserves
        space at the end edge, <code>end-3</code> pins the dismiss button to the end edge, and{' '}
        <code>text-start</code> aligns the copy to whichever side the reader begins on. The full mapping
        is small — <code>ms/me</code>, <code>ps/pe</code>, <code>start/end</code>,{' '}
        <code>border-s/border-e</code>, <code>text-start/text-end</code> — and costs the same number of
        characters as the broken version.
      </p>
    </div>
  );
}
```

## References

- [jakubkrehel/skills — better-typography](https://github.com/jakubkrehel/skills/tree/main/skills/better-typography)
- [MDN: CSS logical properties and values](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_logical_properties_and_values)
