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.
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.
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.
- 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.
- 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.
- 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:
// 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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:
<!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.
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:
<!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-earlybefore the component: left margin 8px, right margin 0px.ltr, with.flushafter 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):
<!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:
- Put
langanddiron thehtmlelement of each page:<html lang="ur" dir="rtl">. The HTML Standard asks authors to set direction with thedirattribute rather than with CSS, so the text stays readable without styles. - Convert everything that names left or right: margins, padding, borders, insets, corners,
float,clearandtext-align. In English nothing moves; in right-to-left pages these are the fixes. - Convert
top,bottom,widthandheightwhere a component may appear in vertical text. In horizontal writing they mean the same thing, so this step can wait. - Split two-to-four-value
marginandpaddingshorthands into-blockand-inlinepairs:padding: 4px 8px 12px 16pxbecomespadding-block: 4px 12pxandpadding-inline: 16px 8px. - List what has no logical form (shadows, transforms, gradients, background positions, script coordinates) and give
each a
:dir(rtl)rule. - Check both directions: in DevTools, set
dir="rtl"on thehtmlelement 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).
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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
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-modeand 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-blockandmargin-inline(start value first) instead of four-valuemargin,paddingandinset, 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
dirandlangonhtml, convert left and right first, and keep physical properties out with Stylelint’sproperty-layout-mappingsrule.
References
- CSS Logical Properties and Values Level 1 (W3C CSS Working Group)
- CSS Writing Modes Level 4: abstract-to-physical mappings (W3C CSS Working Group)
- CSS Snapshot 2026: safe to release pre-CR exceptions (W3C CSS Working Group)
- Selectors Level 4: the directionality pseudo-class :dir() (W3C CSS Working Group)
- CSS Backgrounds and Borders Module Level 3: drop shadows (W3C CSS Working Group)
- CSS Images Module Level 3: linear gradients (W3C CSS Working Group)
- CSSOM View Module: the scrolling area origin (W3C CSS Working Group)
- Unicode Bidirectional Algorithm (UAX #9): rule L4, mirrored glyphs (Unicode Consortium)
- HTML Standard: the dir attribute (WHATWG)
- Logical properties (Baseline, web-features explorer) (W3C WebDX Community Group)
- :dir() (Baseline, web-features explorer) (W3C WebDX Community Group)
- CSS logical properties and values (MDN Web Docs) (Mozilla)
- Stylelint: the property-layout-mappings rule (Stylelint)
- Tailwind CSS v4.2.0 release notes (Tailwind Labs)
- Bootstrap 5.3: RTL (Bootstrap)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress