Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

CSS  Module 13 – International layout: logical properties, writing modes and Indian scripts

Logical properties for any writing direction

Margins, padding, borders, insets and sizes that follow the text direction, a safe way to convert a stylesheet, and the values you still mirror.

  • Intermediate
  • 25 minutes
  • Examples run with Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free: 25 minutes

What you will learn

  • Use inline and block logical properties for margins, padding, borders, insets and sizes
  • Convert a physical stylesheet to logical properties without changing the left-to-right page
  • Predict which declaration wins when a physical and a logical property set the same side
  • Identify the values that still need mirroring by hand, such as shadows, transforms and gradients

Before you start

What you’ll learn · Before you start

What you will learn

  • Use inline and block logical properties for margins, padding, borders, insets and sizes
  • Convert a physical stylesheet to logical properties without changing the left-to-right page
  • Predict which declaration wins when a physical and a logical property set the same side
  • Identify the values that still need mirroring by hand, such as shadows, transforms and gradients

Before you start

On this page

A logical property names a side of a box by the way text flows, not by the screen. margin-inline-start is the margin on the side where a line of text begins: the left in English, the right in Urdu or Arabic, the top in vertical text. Its physical twin, margin-left, stays on the left whatever the language. A component written with logical properties lays itself out correctly in both directions from one stylesheet; written with physical ones, every right-to-left page needs a second set of rules, and the two sets drift apart. Margins, padding, borders, insets and sizes all have logical forms, every current engine supports them, and CSS Snapshot 2026 clears those for sizes, margins, padding and borders for broad use.

This lesson maps the names to the sides, converts one component, and records in Chromium 153, Firefox 155 and WebKit 26.6 what mirrors by itself and what does not.

Inline, block, start and end

Every box has two axes that come from its text. The inline axis is the direction a line of text runs; the block axis is the direction in which lines, paragraphs and other blocks stack. Each axis has a start and an end, so a box has four flow-relative sides: inline-start, inline-end, block-start and block-end.

One box in three writing modes, with its inline-start, inline-end, block-start and block-end sides labelled.English: horizontal-tb, ltrblock-startblock-endinline-startinline-endUrdu: horizontal-tb, rtlblock-startblock-endinline-endinline-startVertical text: vertical-rlinline-startinline-endblock-endblock-start

Where the four flow-relative sides fall in English, Urdu and vertical text

Text description of the diagram

The diagram shows the same box three times, with grey bars standing for lines of text and blue arrows for the two directions.

  1. English, horizontal-tb with dir="ltr": lines run from left to right and stack from top to bottom. The top side is block-start, the bottom side block-end, the left side inline-start and the right side inline-end. The short last line starts at the left.
  2. Urdu, horizontal-tb with dir="rtl": lines still stack from top to bottom, so block-start and block-end stay at the top and the bottom, but each line runs from right to left. The right side is now inline-start and the left side inline-end, and the short last line starts at the right.
  3. Vertical text, vertical-rl: each line runs from top to bottom and the lines stack from right to left. The top side is inline-start, the bottom side inline-end, the right side block-start and the left side block-end.

Three properties decide where those sides fall: writing-mode sets the block axis, direction (set from the HTML dir attribute) sets which end of a line is its start, and text-orientation matters only in vertical text. For margins, padding, borders, insets and sizes, the mapping uses the element’s own writing mode. float and clear are the exception: their inline-start and inline-end values follow the writing mode of the containing block, because a float moves inside its parent’s lines.

Two facts per writing mode are enough to work out the whole table: the side where blocks start, and the side where a left-to-right line starts. A right-to-left line starts at the other end, and every end is opposite its start. This program does exactly that:

Every writing mode, every side JavaScript · logical_map.mjs
// Which physical side each flow-relative side becomes, for every writing mode and direction. Two facts give the whole
// table: where blocks start (the block flow direction) and where a left-to-right line starts (its line-left side).
// A right-to-left line starts at the other end, and the end sides are always opposite the start sides.

const OPPOSITE = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' };

// writing-mode → [the block-start side, the line-left side]
const MODES = {
  'horizontal-tb': ['top', 'left'],
  'vertical-rl': ['right', 'top'],
  'vertical-lr': ['left', 'top'],
  'sideways-rl': ['right', 'top'],
  'sideways-lr': ['left', 'bottom'],
};

/** The physical sides of the four flow-relative sides. */
function sides(writingMode, direction) {
  const [blockStart, lineLeft] = MODES[writingMode];
  const inlineStart = direction === 'ltr' ? lineLeft : OPPOSITE[lineLeft];
  return { inlineStart, inlineEnd: OPPOSITE[inlineStart], blockStart, blockEnd: OPPOSITE[blockStart] };
}

/** A corner property such as border-start-end-radius: the block side first, then the inline side. */
function corner(block, inline, s) {
  const b = block === 'start' ? s.blockStart : s.blockEnd;
  const i = inline === 'start' ? s.inlineStart : s.inlineEnd;
  const [vertical, horizontal] = b === 'top' || b === 'bottom' ? [b, i] : [i, b];
  return `${vertical}-${horizontal}`;
}

const row = (cells, widths) => cells.map((c, i) => String(c).padEnd(widths[i])).join('').trimEnd();
const W = [15, 5, 9, 9, 9, 9, 10];
console.log(row(['writing-mode', 'dir', 'i-start', 'i-end', 'b-start', 'b-end', 'start-end'], W));
for (const mode of Object.keys(MODES)) {
  for (const dir of ['ltr', 'rtl']) {
    const s = sides(mode, dir);
    console.log(row([mode, dir, s.inlineStart, s.inlineEnd, s.blockStart, s.blockEnd, corner('start', 'end', s)], W));
  }
}
console.log('\ni = inline, b = block; start-end = the corner of border-start-end-radius');
console.log('inline-size is width in horizontal-tb and height in the four vertical modes.');

// One declaration, five meanings.
console.log('\nmargin-inline-start: 16px sets');
for (const mode of Object.keys(MODES)) {
  const ltr = sides(mode, 'ltr').inlineStart;
  const rtl = sides(mode, 'rtl').inlineStart;
  console.log(`  ${mode.padEnd(14)} margin-${ltr} (ltr), margin-${rtl} (rtl)`);
}

Output

writing-mode   dir  i-start  i-end    b-start  b-end    start-end
horizontal-tb  ltr  left     right    top      bottom   top-right
horizontal-tb  rtl  right    left     top      bottom   top-left
vertical-rl    ltr  top      bottom   right    left     bottom-right
vertical-rl    rtl  bottom   top      right    left     top-right
vertical-lr    ltr  top      bottom   left     right    bottom-left
vertical-lr    rtl  bottom   top      left     right    top-left
sideways-rl    ltr  top      bottom   right    left     bottom-right
sideways-rl    rtl  bottom   top      right    left     top-right
sideways-lr    ltr  bottom   top      left     right    top-left
sideways-lr    rtl  top      bottom   left     right    bottom-left

i = inline, b = block; start-end = the corner of border-start-end-radius
inline-size is width in horizontal-tb and height in the four vertical modes.

margin-inline-start: 16px sets
  horizontal-tb  margin-left (ltr), margin-right (rtl)
  vertical-rl    margin-top (ltr), margin-bottom (rtl)
  vertical-lr    margin-top (ltr), margin-bottom (rtl)
  sideways-rl    margin-top (ltr), margin-bottom (rtl)
  sideways-lr    margin-bottom (ltr), margin-top (rtl)

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node logical_map.mjs

The program’s table is not an approximation. Setting margin-inline-start, margin-block-start and border-start-end-radius in all ten combinations and reading back the physical properties with getComputedStyle() gave the same 30 sides in each of the three engines. Two rows deserve a second look. In sideways-lr, lines run from bottom to top, so inline-start is the bottom. In the vertical modes with text-orientation: upright, the used direction is always ltr, whatever dir says.

The logical properties, family by family

In horizontal left-to-right text, each logical property does exactly what its physical counterpart does, which is what makes a conversion safe. The names follow one pattern, shown here for an English page:

Physical Logical
margin-left margin-inline-start
margin-right margin-inline-end
margin-top margin-block-start
padding-bottom padding-block-end
border-left-color border-inline-start-color
left inset-inline-start
top inset-block-start
width, max-width inline-size, max-inline-size
height, min-height block-size, min-block-size
border-top-right-radius border-start-end-radius

Margins, padding, borders and insets also have two-sided shorthands: margin-inline and margin-block, padding-inline and padding-block, and so on. Each takes the start value first: padding-inline: 12px 40px puts 12 px at the start and 40 px at the end, and margin-inline: auto centres a block in any direction. A corner’s name gives the block side first and the inline side second, so border-start-end-radius is the top-right corner in English and the top-left one in Urdu.

Some properties take logical values instead: text-align: start and end, and float and clear with inline-start and inline-end. The pattern also reaches scroll-margin-inline, scroll-padding-block, overscroll-behavior-inline and the units vi and vb, 1 % of the viewport’s inline and block size.

One trap hides in the four-sided shorthands: margin, padding, border-width and inset stay physical, so inset: 0 auto auto 0 still means top, right, bottom and left. CSS Logical Properties Level 1 drafts a logical keyword for them (margin: logical 1em 2em), but marks the syntax as almost certain to change, and none of the three engines accepts it. Write margin-block and margin-inline instead.

Logical properties for sizes, margins, padding, borders and insets are Baseline widely available, and so are the inline-start and inline-end values of float and clear. overflow-inline and overflow-block are newer and only newly available.

One stylesheet, two directions

A comment thread shows what changes. The same markup appears three times: in English with the logical stylesheet, in Urdu with dir="rtl" and the same logical stylesheet, and in Urdu with an older physical stylesheet written for English. The script at the end reports where each part landed:

One thread in English and in Urdu HTML · comment-thread.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>One comment thread, two directions</title>
  <style>
    body { font: 16px/1.5 system-ui, sans-serif; margin: 16px; }
    .thread { inline-size: 420px; margin-block-end: 24px; }
    .comment { display: flex; gap: 12px; position: relative; margin-block-end: 8px; }
    .avatar { flex: none; inline-size: 32px; block-size: 32px; border-radius: 50%; background: #cbd5e1; }
    .comment p { flex: 1; margin: 0; }

    /* Before: physical properties, written with left-to-right in mind */
    .physical .comment { padding: 12px 56px 12px 12px; border-left: 4px solid #2563eb; text-align: left; }
    .physical .reply { margin-left: 40px; }
    .physical .badge { position: absolute; top: 8px; right: 8px; }

    /* After: logical properties, one set of rules for both directions */
    .logical .comment {
      padding-block: 12px;
      padding-inline: 12px 56px; /* start, then end: room for the badge */
      border-inline-start: 4px solid #2563eb;
      text-align: start;
    }
    .logical .reply { margin-inline-start: 40px; }
    .logical .badge { position: absolute; inset-block-start: 8px; inset-inline-end: 8px; }
  </style>
</head>
<body>
  <section class="thread logical" id="english" dir="ltr" lang="en">
    <div class="comment"><span class="avatar"></span><p>Ayesha: This recipe is really easy, thank you!</p><b class="badge">New</b></div>
    <div class="comment reply"><span class="avatar"></span><p>Farhan: I made it yesterday and everyone loved it.</p></div>
  </section>
  <section class="thread logical" id="urdu" dir="rtl" lang="ur">
    <div class="comment"><span class="avatar"></span><p>عائشہ: یہ ترکیب بہت آسان ہے، شکریہ!</p><b class="badge">نیا</b></div>
    <div class="comment reply"><span class="avatar"></span><p>فرحان: میں نے کل بنائی، سب کو پسند آئی۔</p></div>
  </section>
  <section class="thread physical" id="urdu-physical" dir="rtl" lang="ur">
    <div class="comment"><span class="avatar"></span><p>عائشہ: یہ ترکیب بہت آسان ہے، شکریہ!</p><b class="badge">نیا</b></div>
    <div class="comment reply"><span class="avatar"></span><p>فرحان: میں نے کل بنائی، سب کو پسند آئی۔</p></div>
  </section>
  <script>
    // Where each part landed, measured from the edges of its comment box.
    const side = (a, b) => (a < b ? 'left' : 'right');
    for (const thread of document.querySelectorAll('.thread')) {
      const [first, reply] = thread.querySelectorAll('.comment');
      const box = first.getBoundingClientRect();
      const cs = getComputedStyle(first);
      const border = parseFloat(cs.borderLeftWidth) ? `left ${cs.borderLeftWidth}` : `right ${cs.borderRightWidth}`;
      const indent = reply.getBoundingClientRect();
      const t = thread.getBoundingClientRect();
      const replyIndent = indent.left - t.left > 0 ? `left ${indent.left - t.left}px` : `right ${t.right - indent.right}px`;
      const avatar = first.querySelector('.avatar').getBoundingClientRect();
      const badge = first.querySelector('.badge').getBoundingClientRect();
      const overlap = !(badge.right <= avatar.left || badge.left >= avatar.right || badge.bottom <= avatar.top || badge.top >= avatar.bottom);
      // The last line of the text (short, so it shows the alignment): the union of its rectangles.
      const range = document.createRange();
      range.selectNodeContents(first.querySelector('p'));
      const rects = [...range.getClientRects()];
      const lastTop = Math.max(...rects.map((r) => r.top));
      const onLast = rects.filter((r) => Math.abs(r.top - lastTop) < 2);
      const line = { left: Math.min(...onLast.map((r) => r.left)), right: Math.max(...onLast.map((r) => r.right)) };
      const p = first.querySelector('p').getBoundingClientRect();
      console.log(`${thread.id}: border ${border} | reply indent ${replyIndent} | avatar ${side(avatar.left - box.left, box.right - avatar.right)}` +
        ` | badge ${Math.round(Math.min(badge.left - box.left, box.right - badge.right))}px from the ${side(badge.left - box.left, box.right - badge.right)}` +
        `${overlap ? ', over the avatar' : ''} | last line ${Math.round(line.left - p.left)}px from the left, ${Math.round(p.right - line.right)}px from the right`);
    }
  </script>
</body>
</html>

All three engines reported the same sides:

  • English, logical stylesheet: the accent border, the 40 px reply indent, the avatar and the start of the last line are on the left; the badge sits 8 px in from the right.
  • Urdu, logical stylesheet: all of them are on the right, and the badge sits 8 px in from the left.
  • Urdu, physical stylesheet: the avatar is on the right, but the accent border, the reply indent and the start of the last line are on the left, and the badge stays 8 px in from the right.

The logical version mirrors as a whole. The physical one fails in a way that is easy to miss: the avatar did move to the right, because a flex row follows the direction of its container, but the accent border and the reply indent stayed on the left, the badge stayed in the right corner beside the avatar, and the Urdu text lines up at the left edge, away from its avatar. Flexbox, grid and the default text-align: start already follow the direction, so a half-converted component looks nearly right, and that is how these bugs reach production.

HTML, CSS & JS Playground (Online Editor) Paste comment-thread.html into the HTML box, then change dir on a section to see each rule follow the text.

When a physical and a logical declaration meet

Most real stylesheets are mixed: new components use logical properties while older utility classes still say left and right. CSS has a rule for this. A logical property and the physical property it maps to form a pair that shares one computed value, and the cascade compares their declarations as if they were one property, so the declaration that wins the cascade decides both. In English, margin-inline-start pairs with margin-left; in Urdu, with margin-right. That one difference changes which rules collide:

A logical margin and a physical utility HTML · mixed-margins.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>A logical and a physical margin on one button</title>
  <style>
    body { font: 16px/1.5 system-ui, sans-serif; margin: 16px; }
    /* A utility that comes BEFORE the component rule. */
    .flush-early { margin-left: 0; }
    /* The component: space before the icon button, on its start side. */
    .icon-button { margin-inline-start: 8px; }
    /* An older utility class, later in the file, meant to remove that space. */
    .flush { margin-left: 0; }
    /* The same utility written logically. */
    .flush-start { margin-inline-start: 0; }
  </style>
</head>
<body>
  <p dir="ltr"><button class="icon-button flush-early" id="ltr-flush-early">Save</button></p>
  <p dir="ltr"><button class="icon-button flush" id="ltr-flush">Save</button></p>
  <p dir="rtl"><button class="icon-button flush" id="rtl-flush">محفوظ کریں</button></p>
  <p dir="rtl"><button class="icon-button flush-start" id="rtl-flush-start">محفوظ کریں</button></p>
  <script>
    for (const id of ['ltr-flush-early', 'ltr-flush', 'rtl-flush', 'rtl-flush-start']) {
      const cs = getComputedStyle(document.getElementById(id));
      console.log(`${id}: margin-left ${cs.marginLeft}, margin-right ${cs.marginRight}, margin-inline-start ${cs.marginInlineStart}`);
    }
  </script>
</body>
</html>
  • ltr, with .flush-early before the component: left margin 8px, right margin 0px.
  • ltr, with .flush after the component: left 0px, right 0px.
  • rtl, with .flush: left 0px, right 8px.
  • rtl, with .flush-start: left 0px, right 0px.

These are the computed margins, the same in all three engines, and margin-inline-start always equalled the side it pairs with. In left-to-right text the two declarations fight over the same side and the later one wins, whichever kind it is. In right-to-left text .flush sets the left margin, which is now the end side, and the 8 px start margin survives on the right. A utility that used to remove the space now does nothing visible. getComputedStyle() always reports the same value for both properties of a pair, so DevTools shows the winner under either name.

The practical rule: don’t mix the two kinds for the same side of the same component. Convert shared utility classes first, then the components that use them.

What logical properties don’t mirror

Some values contain a direction of their own, and no logical property changes them. CSS Backgrounds defines a shadow’s first length as an offset to the right, CSS Images defines to right as a gradient angle of 90deg, and a positive translateX() always moves an element to the right. The next example measures them in a right-to-left card and then mirrors them with :dir(rtl):

Shadows, arrows, gradients and scrolling in Urdu HTML · mirroring.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>What logical properties don't mirror</title>
  <style>
    body { font: 16px/1.5 system-ui, sans-serif; margin: 16px; }
    .card { inline-size: 200px; padding: 12px; margin-block-end: 16px; box-shadow: 6px 6px 0 #94a3b8; }
    .next::after { content: "\2192"; display: inline-block; margin-inline-start: 4px; } /* → */
    .meter { block-size: 8px; background: linear-gradient(to right, #2563eb 70%, #e2e8f0 70%); }
    .strip { inline-size: 200px; overflow-x: auto; white-space: nowrap; }

    /* Mirroring with :dir(): matches the element's own direction, inherited or from dir="auto". */
    .card.dir-fix:dir(rtl) { box-shadow: -6px 6px 0 #94a3b8; }
    .dir-fix .next:dir(rtl)::after { transform: scaleX(-1); }
    .meter.dir-fix:dir(rtl) { background: linear-gradient(to left, #2563eb 70%, #e2e8f0 70%); }

    /* Mirroring with an attribute selector: matches anything below a dir="rtl" ancestor. */
    [dir="rtl"] .card.attr-fix { box-shadow: -6px 6px 0 #94a3b8; }
  </style>
</head>
<body>
  <div dir="ltr"><div class="card" id="ltr">English card <span class="next">Next</span></div></div>
  <div dir="rtl"><div class="card" id="rtl">اردو کارڈ <span class="next">اگلا</span></div></div>
  <div dir="rtl"><div class="card dir-fix" id="rtl-dir-fix">اردو کارڈ <span class="next">اگلا</span></div></div>
  <div dir="rtl"><div dir="ltr"><div class="card dir-fix" id="nested-ltr-dir"><span lang="en">English quote</span></div></div></div>
  <div dir="rtl"><div dir="ltr"><div class="card attr-fix" id="nested-ltr-attr"><span lang="en">English quote</span></div></div></div>
  <div class="meter" id="meter-ltr"></div>
  <div dir="rtl"><div class="meter" id="meter-rtl"></div></div>
  <div dir="rtl"><div class="meter dir-fix" id="meter-rtl-fix"></div></div>
  <div class="strip" id="strip" dir="rtl">۱ ۲ ۳ ۴ ۵ ۶ ۷ ۸ ۹ ۱۰ ۱۱ ۱۲ ۱۳ ۱۴ ۱۵ ۱۶ ۱۷ ۱۸ ۱۹ ۲۰ ۲۱ ۲۲ ۲۳ ۲۴ ۲۵ ۲۶ ۲۷ ۲۸ ۲۹ ۳۰</div>
  <script>
    const cs = (id, pseudo) => getComputedStyle(document.getElementById(id), pseudo ?? null);
    for (const id of ['ltr', 'rtl', 'rtl-dir-fix', 'nested-ltr-dir', 'nested-ltr-attr']) {
      const shadow = cs(id).boxShadow;
      const dx = Number(/\)\s*(-?\d+)px/.exec(shadow)?.[1] ?? NaN);
      console.log(`${id}: direction ${cs(id).direction}, shadow falls to the ${dx < 0 ? 'left' : 'right'} (${dx}px)`);
    }
    for (const id of ['rtl', 'rtl-dir-fix']) {
      const arrow = getComputedStyle(document.querySelector(`#${id} .next`), '::after').transform;
      console.log(`${id} arrow: transform ${arrow}`);
    }
    for (const id of ['meter-ltr', 'meter-rtl', 'meter-rtl-fix']) {
      const fill = /to left/.test(cs(id).backgroundImage) || /270deg/.test(cs(id).backgroundImage) ? 'from the right' : 'from the left';
      console.log(`${id}: fills ${fill}`);
    }
    // Script coordinates stay physical too: in a right-to-left scroller, scrollLeft starts at 0 and goes negative.
    const strip = document.getElementById('strip');
    const start = strip.scrollLeft;
    strip.scrollLeft = -10000;
    console.log(`strip: scrollWidth ${strip.scrollWidth}, clientWidth ${strip.clientWidth}, scrollLeft at the start ${start}, at the far end ${strip.scrollLeft}`);
  </script>
</body>
</html>
What In dir="rtl", untouched With a :dir(rtl) rule
box-shadow: 6px 6px 0 falls to the right (6px) falls to the left (-6px)
A → arrow after “next” still points right scaleX(-1) points it left
linear-gradient(to right, …) meter fills from the left fills from the right
scrollLeft of a scroller 0 at the start, negative towards the end not a CSS value: handle it in script

The three engines agreed on every row. Browsers do mirror some characters by themselves: the Unicode Bidirectional Algorithm draws a mirrored glyph for characters marked as mirrored, such as (, [ and <, when they fall in right-to-left text. Arrows are not on that list, so a “next” arrow needs a rule of its own, or an icon chosen for each direction.

Scripts see physical coordinates too. getBoundingClientRect() and offsetLeft count from the left of the viewport, and in a right-to-left scroller scrollLeft starts at 0 and goes negative as you scroll towards the end: -297 px at the far end in Chromium, -360 px in Firefox (its font for the digits is wider) and -298 px in WebKit. CSSOM View defines it this way, because the origin of a scrolling area is its top-left padding edge in its starting position. Code that positions tooltips or carousels by hand needs a branch for each direction.

:dir(rtl), not [dir=“rtl”]

The fixes above use :dir(rtl), which matches an element by its own direction, inherited or set with dir="auto". The older pattern, [dir="rtl"] .card, matches every card inside a right-to-left element, even one inside a nested dir="ltr" block. The example has such a case, an English quote in an Urdu page, whose own direction is ltr:

  • mirrored with .card:dir(rtl), its shadow stays on the right, which is correct;
  • mirrored with [dir="rtl"] .card, its shadow moves to the left, which is wrong.

:dir() is Baseline widely available, and CSS Snapshot 2026 clears it for broad use, so new code can rely on it.

Converting a stylesheet safely

A conversion should change nothing in English and fix the other direction. This order keeps every step checkable:

  1. Put lang and dir on the html element of each page: <html lang="ur" dir="rtl">. The HTML Standard asks authors to set direction with the dir attribute rather than with CSS, so the text stays readable without styles.
  2. Convert everything that names left or right: margins, padding, borders, insets, corners, float, clear and text-align. In English nothing moves; in right-to-left pages these are the fixes.
  3. Convert top, bottom, width and height where a component may appear in vertical text. In horizontal writing they mean the same thing, so this step can wait.
  4. Split two-to-four-value margin and padding shorthands into -block and -inline pairs: padding: 4px 8px 12px 16px becomes padding-block: 4px 12px and padding-inline: 16px 8px.
  5. List what has no logical form (shadows, transforms, gradients, background positions, script coordinates) and give each a :dir(rtl) rule.
  6. Check both directions: in DevTools, set dir="rtl" on the html element and compare the computed values.

To keep physical properties from coming back, Stylelint (since 17.8) has a built-in rule, property-layout-mappings. Set to "flow-relative", it reports margin-left and width, and with languageOptions.directionality configured it can fix them; unit-layout-mappings does the same for vw and vh.

Frameworks have moved too. In Tailwind CSS 4, px-4 and py-4 already write padding-inline and padding-block, and ps-*, pe-*, ms-* and me-* set the start and end sides; version 4.2 added pbs-*, mbs-*, border-bs-*, inline-* and block-* sizes and inset-s-* and inset-e-*, and deprecated start-* and end-* in their favour. Its rtl: and ltr: variants match with :dir() and [dir] selectors. Bootstrap 5.3 takes the other route: it builds a separate right-to-left stylesheet with RTLCSS, which rewrites physical properties, and names its classes by start and end (ms-3, text-start).

CSS Toolkit Run a converted stylesheet through its validator to catch a mistyped logical property, which a browser drops without a word.

Practise: convert physical declarations

Write the core of a conversion tool: a function that turns one physical declaration into its logical form and refuses the ones that have none.

Exercise · Medium · JavaScript

Convert physical declarations to logical properties

A stylesheet written for English only uses left, right, top, bottom, width and height. Write toLogical(property, value) in to_logical.mjs and export it. It converts one declaration of such a stylesheet, which was written for horizontal left-to-right text, and returns a list of [property, value] pairs, with the value trimmed:

- Sides of margin, padding and border, with or without -width, -style or -color: top becomes block-start, bottom becomes block-end, left becomes inline-start and right becomes inline-end, so margin-left: 16px gives [['margin-inline-start', '16px']]. - The offsets top, right, bottom and left become inset-block-start, inset-inline-end, inset-block-end and inset-inline-start. - width and height, also with min- or max-, become inline-size and block-size. - Corners: border-top-left-radius becomes border-start-start-radius, top-right becomes start-end, bottom-left becomes end-start and bottom-right becomes end-end. - The values left and right of float and clear become inline-start and inline-end; those of text-align become start and end. - margin and padding with two to four values become a -block pair (top, bottom) and an -inline pair (left, right), in that order. A pair whose two values are the same is written once: margin: 0 auto gives [['margin-block', '0'], ['margin-inline', 'auto']]. With one value, the declaration stays as it is. - box-shadow and text-shadow have no logical form. When the first length of any of their shadows (the horizontal offset) is not zero, return null: someone has to mirror it with :dir(rtl). Otherwise keep it. - Anything else comes back unchanged.

The sample tests import toLogical from to_logical.mjs and run in your browser.

Starter code · to_logical.mjs

/**
 * Converts one physical declaration, written for horizontal left-to-right text, to logical declarations.
 * Returns a list of [property, value] pairs, or null when the declaration has to stay physical.
 */
export function toLogical(property, value) {
  // Replace this line with your code.
  return [[property, value.trim()]];
}
The sample tests · to_logical.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { toLogical } from './to_logical.mjs';

test('sides of margins, padding and borders', () => {
  assert.deepEqual(toLogical('margin-left', '16px'), [['margin-inline-start', '16px']]);
  assert.deepEqual(toLogical('padding-bottom', '8px'), [['padding-block-end', '8px']]);
  assert.deepEqual(toLogical('border-right', '1px solid #ccc'), [['border-inline-end', '1px solid #ccc']]);
  assert.deepEqual(toLogical('border-top-color', 'red'), [['border-block-start-color', 'red']]);
});

test('offsets, sizes and corners', () => {
  assert.deepEqual(toLogical('left', '0'), [['inset-inline-start', '0']]);
  assert.deepEqual(toLogical('bottom', '12px'), [['inset-block-end', '12px']]);
  assert.deepEqual(toLogical('max-width', '60ch'), [['max-inline-size', '60ch']]);
  assert.deepEqual(toLogical('height', '100%'), [['block-size', '100%']]);
  assert.deepEqual(toLogical('border-top-right-radius', '8px'), [['border-start-end-radius', '8px']]);
  assert.deepEqual(toLogical('border-bottom-left-radius', '8px'), [['border-end-start-radius', '8px']]);
});

test('values that name a side', () => {
  assert.deepEqual(toLogical('float', 'right'), [['float', 'inline-end']]);
  assert.deepEqual(toLogical('clear', 'left'), [['clear', 'inline-start']]);
  assert.deepEqual(toLogical('text-align', 'left'), [['text-align', 'start']]);
  assert.deepEqual(toLogical('text-align', 'center'), [['text-align', 'center']]);
});

test('four-sided shorthands', () => {
  assert.deepEqual(toLogical('padding', '4px 8px 12px 16px'), [['padding-block', '4px 12px'], ['padding-inline', '16px 8px']]);
  assert.deepEqual(toLogical('margin', '0 auto'), [['margin-block', '0'], ['margin-inline', 'auto']]);
  assert.deepEqual(toLogical('margin', '8px 16px 24px'), [['margin-block', '8px 24px'], ['margin-inline', '16px']]);
  assert.deepEqual(toLogical('padding', '12px'), [['padding', '12px']]);
});

test('shadows with a horizontal offset stay physical', () => {
  assert.equal(toLogical('box-shadow', '4px 4px 0 #94a3b8'), null);
  assert.equal(toLogical('text-shadow', '#0004 -1px 1px 2px'), null);
  assert.deepEqual(toLogical('box-shadow', '0 2px 6px #0003'), [['box-shadow', '0 2px 6px #0003']]);
});

test('everything else is left as it is', () => {
  assert.deepEqual(toLogical('color', 'rebeccapurple'), [['color', 'rebeccapurple']]);
  assert.deepEqual(toLogical('display', 'flex'), [['display', 'flex']]);
});
A hint

Start with a lookup table for the sides: top is block-start, bottom is block-end, left is inline-start and right is inline-end. One regular expression such as /^(margin|padding|border)-(top|right|bottom|left)(-width|-style|-color)?$/ then covers every side property, and a second table turns width and height into inline-size and block-size.

The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 6 A paragraph has dir="rtl" and the default writing-mode. Which physical side does padding-inline-start: 24px pad?

    Choose one answer.

    Show the answer to question 1

    Answer: The right side

    In horizontal writing the inline axis runs across the line, and in right-to-left text a line starts at the right, so padding-inline-start is the right padding. In English (dir="ltr") the same declaration pads the left, and in vertical-rl it pads the top.

  2. Question 2 of 6 An English stylesheet has padding: 4px 8px 12px 16px. Which logical declarations draw the same box in English and mirror it in Urdu?

    Choose one answer.

    Show the answer to question 2

    Answer: padding-block: 4px 12px; padding-inline: 16px 8px

    The four values are top, right, bottom and left. The block pair takes top then bottom (4px 12px); the inline pair takes the start side first, which is the left in English, so it is 16px then 8px. The logical keyword is only a draft whose syntax is expected to change, and none of the three engines accepts it.

  3. Question 3 of 6 A button has .icon-button { margin-inline-start: 8px; } and, later in the file, .flush { margin-left: 0; }. Both classes are on the button, inside a dir="rtl" paragraph. What is its computed margin-right, in pixels?

    Type a number.

    Show the answer to question 3

    Answer: 8 px

    In right-to-left text margin-inline-start pairs with margin-right, so the two declarations set different sides and do not compete: the right margin stays 8px and the left one is 0. In English they would pair up, and the later margin-left: 0 would win for both. All three engines reported these values in the lesson's measurement.

  4. Question 4 of 6 An English component is converted to logical properties and then shown in Urdu. Which of these still need a :dir(rtl) rule?

    Choose every answer that is right.

    Show the answer to question 4

    Answer:

    • background: linear-gradient(to right, #2563eb 70%, #e2e8f0 70%)
    • box-shadow: 4px 4px 0 #94a3b8
    • transform: translateX(8px)

    A shadow's first length is defined as an offset to the right, to right is a fixed gradient angle of 90deg, and a positive translateX() always moves right, so none of them follows the text. Logical properties and the start value of text-align mirror by themselves.

  5. Question 5 of 6 Which corner does border-start-end-radius: 12px round on a box with dir="rtl" and the default writing mode?

    Choose one answer.

    Show the answer to question 5

    Answer: Top-left

    The first word is the block side and the second the inline side. Block-start is the top in horizontal writing, and the inline end of a right-to-left line is its left side, so the corner is the top-left one. In English the same property rounds the top-right corner.

  6. Question 6 of 6 An Urdu page (<html dir="rtl">) quotes an English paragraph in <blockquote dir="ltr">. Cards inside the quote have mirrored shadows that point the wrong way. Which selector caused it?

    Choose one answer.

    Show the answer to question 6

    Answer: [dir="rtl"] .card

    An attribute selector only checks for an ancestor with dir="rtl", and the html element is one, so it matches cards inside the left-to-right quote too. :dir(rtl) matches by the element's own direction, which inside the quote is left to right. The lesson's measurement showed the same result in all three engines.

Interview questions

Fresher · What is the difference between margin-left and margin-inline-start?

margin-left always sets the margin on the left side of the box. margin-inline-start sets the margin on the side where lines of text start, which depends on the element’s writing mode and direction: the left in English, the right in Urdu or Arabic (dir="rtl"), and the top in vertical text such as writing-mode: vertical-rl. In horizontal left-to-right text the two do the same thing, which is why converting is safe. When both are declared, they share one computed value and the one that wins the cascade decides it, so in English the later declaration wins, while in Urdu margin-left pairs with margin-inline-end instead and both apply to different sides. Logical properties let one stylesheet serve both directions; shadows, transforms and gradients still need mirroring with :dir(rtl).

Key takeaways

  • The inline axis is the way a line of text runs, the block axis the way lines stack; logical properties name the start and end of each, and the element’s own writing-mode and direction decide which physical side that is.
  • In horizontal left-to-right text every logical property equals its physical twin, so converting changes nothing in English and fixes right-to-left pages.
  • Use margin-block and margin-inline (start value first) instead of four-value margin, padding and inset, which stay physical.
  • A logical and a physical property on the same side share one computed value; in a mixed stylesheet they collide in one direction and both apply in the other.
  • Shadow offsets, transforms, gradient directions, arrow icons and script coordinates do not mirror: give them :dir(rtl) rules, not [dir="rtl"] ones.
  • Set dir and lang on html, convert left and right first, and keep physical properties out with Stylelint’s property-layout-mappings rule.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.