CSS Module 13 – International layout: logical properties, writing modes and Indian scripts
Writing modes, vertical and bidirectional text
Set vertical text with writing-mode, mix English and Urdu safely with dir, bdi and :dir(), and keep phone numbers and codes in order.
What you will learn
- Set the direction of pages, blocks and user-written text with the dir attribute and :dir()
- Explain why numbers and codes change order inside right-to-left text, and isolate them
- Set vertical writing with writing-mode and text-orientation instead of rotating boxes
- Use text-combine-upright and vertical form controls where they help
Before you start
What you’ll learn · Before you start
What you will learn
- Set the direction of pages, blocks and user-written text with the dir attribute and :dir()
- Explain why numbers and codes change order inside right-to-left text, and isolate them
- Set vertical writing with writing-mode and text-orientation instead of rotating boxes
- Use text-combine-upright and vertical form controls where they help
Before you start
On this page
Two settings decide how text sits in a box. writing-mode chooses whether lines run across the box or down it, and in
which direction they stack; the direction, set with the HTML dir attribute, chooses which end of a line is its
start: the left for English, the right for Urdu and Arabic. Pages often mix both directions, an English product code in
an Urdu sentence or an Urdu name in an English table, and then the Unicode Bidirectional Algorithm decides the order in
which the pieces appear. It gets ordinary text right, but numbers, codes and punctuation at the edges of a run can jump
to the wrong side unless the run is isolated. Vertical text has its own trap: rotating a box with transform turns
only its painting, while writing-mode turns its layout.
This lesson sets direction the way the HTML Standard asks, reproduces the reordering bugs in Chromium 153, Firefox 155 and WebKit 26.6, runs the part of the algorithm that explains them, and then lays out vertical labels and signs.
Set the direction with dir, not with CSS
The direction of a page belongs in its markup: <html lang="ur" dir="rtl">. The HTML Standard asks authors to use the
dir attribute rather than CSS, so the text stays readable when styles are missing, and CSS Writing Modes says authors
should not use the direction and unicode-bidi properties in HTML documents. Those properties exist so that other
document languages can map their own markup to CSS. dir takes three values:
ltrandrtlset the direction of the element and everything inside it. The browser’s own stylesheet also gives every element with adirattributeunicode-bidi: isolate, so its content is laid out on its own and cannot disturb the text around it.autolooks at the text and uses the direction of the first strong character, a letter of a left-to-right or a right-to-left script. Digits, punctuation, spaces and emoji are skipped. Text with no strong character getsltr.
auto is meant for text you did not write: reviews, chat messages, names, search queries. The HTML Standard calls its
rule very crude and asks authors to use it only when nothing better is known. The reviews example shows why:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Reviews typed by users, shown with dir="auto"</title>
<style>
body { font: 16px/1.5 system-ui, sans-serif; margin: 16px; }
.review { inline-size: 360px; padding-inline-start: 12px; border-inline-start: 3px solid #0f766e; text-align: start; }
.review:dir(rtl) { font-size: 18px; } /* Urdu needs a slightly larger size; see the Indian scripts lesson */
</style>
</head>
<body>
<!-- Each review keeps the direction of the text the user typed. -->
<p class="review" dir="auto">Fast delivery, well packed.</p>
<p class="review" dir="auto">ڈلیوری تیز تھی، پیکنگ اچھی تھی۔</p>
<p class="review" dir="auto">5 ستارے، بہت اچھا!</p>
<p class="review" dir="auto">Samsung کا یہ فون بہت اچھا ہے۔</p>
<p class="review" dir="auto">!!! 😀</p>
<textarea class="review" dir="auto" aria-label="Your review"></textarea>
<script>
window.measure = async () => {
const out = [];
document.querySelectorAll('p.review').forEach((p, i) => {
out.push(`review ${i + 1}: direction ${getComputedStyle(p).direction}, matches :dir(rtl) ${p.matches(':dir(rtl)')}`);
});
const box = document.querySelector('textarea');
for (const [what, value] of [['nothing typed', ''], ['Urdu typed', 'بہت'], ['English typed', 'Very good']]) {
box.value = value;
out.push(`textarea, ${what}: direction ${getComputedStyle(box).direction}`);
}
return out;
};
</script>
</body>
</html> | Review | What dir="auto" gave |
|---|---|
| Fast delivery, well packed. | ltr, correct |
| An Urdu sentence about the delivery | rtl, correct |
| “5” and stars in Urdu | rtl, correct: the digit is skipped |
| “Samsung” followed by an Urdu sentence | ltr, wrong: the brand name is the first letter |
| “!!! 😀” | ltr: no strong character, so the default |
All three engines gave these results, and a textarea with dir="auto" switched from ltr to rtl as soon as Urdu
text was put in it. The fourth row is the weakness: an Urdu review that starts with an English word is laid out left to
right. When you know more than the first letter, say so. Store the direction with the text: a form control with
dirname="review.dir" sends review.dir=rtl or ltr along with the review. Or set dir from the language the
writer chose.
:dir(rtl) and :dir(ltr) match the direction each element ends up with, including the result of auto, which an
attribute selector such as [dir="rtl"] cannot see. Phone fields are special: an <input type="tel"> without its own
dir is always left to right.
Why numbers and codes change order
Inside one line, the Unicode Bidirectional Algorithm sorts characters into strong ones (Latin letters are left to right, Urdu letters right to left), weak ones (digits, the plus and minus signs, the colon) and neutral ones (spaces and most punctuation). Each character gets an embedding level, even for left to right and odd for right to left, and the line is drawn by reversing the runs of the higher levels. Weak and neutral characters take their level from their neighbours, and that is where things go wrong. An Urdu order card shows three cases, and an English dashboard a fourth:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>An Urdu order card with English codes</title>
<style>
body { font: 18px/1.6 system-ui, sans-serif; margin: 16px; }
.card { inline-size: 360px; padding: 12px 16px; border: 1px solid #94a3b8; margin-block-end: 12px; }
.card p { margin: 0; }
</style>
</head>
<body>
<!-- Before: the codes sit in the Urdu sentence with no isolation. -->
<div class="card" dir="rtl" lang="ur" id="plain">
<p data-check="AB-1234">ماڈل AB-1234 کا آرڈر کل پہنچے گا۔</p>
<p data-check="+91 12345 67890">فون: +91 12345 67890</p>
<p data-check="36-KX">رنگ کا کوڈ 36-KX ہے۔</p>
</div>
<!-- After: each piece of left-to-right data is isolated. -->
<div class="card" dir="rtl" lang="ur" id="isolated">
<p data-check="AB-1234">ماڈل <bdi>AB-1234</bdi> کا آرڈر کل پہنچے گا۔</p>
<p data-check="+91 12345 67890">فون: <span dir="ltr">+91 12345 67890</span></p>
<p data-check="36-KX">رنگ کا کوڈ <bdi>36-KX</bdi> ہے۔</p>
</div>
<!-- An English dashboard that shows customer names as they were typed. -->
<div class="card" dir="ltr" lang="en" id="dashboard">
<p data-check="عائشہ: 3">Customer عائشہ: 3 orders this week</p>
<p data-check="عائشہ: 3">Customer <bdi>عائشہ</bdi>: 3 orders this week</p>
</div>
<script>
// What a reader sees, left to right: the characters of each piece, sorted by where they were drawn.
function seen(p, text) {
const node = [...p.childNodes].flatMap((n) => (n.nodeType === 3 ? [n] : [...n.childNodes])).find((n) => n.data.includes(text));
const start = node.data.indexOf(text);
const chars = [];
for (let i = 0; i < text.length; i++) {
const r = document.createRange();
r.setStart(node, start + i);
r.setEnd(node, start + i + 1);
const box = [...r.getClientRects()].find((b) => b.width > 0); // skip empty boxes at run edges
chars.push({ ch: text[i], x: box.left + box.width / 2 });
}
return chars.sort((a, b) => a.x - b.x).map((c) => c.ch).join('');
}
// Where the count landed: before (left of) or after (right of) the name.
function countSide(p) {
const node = [...p.childNodes].flatMap((n) => (n.nodeType === 3 ? [n] : [...n.childNodes])).find((n) => n.data.includes('3'));
const nameNode = [...p.childNodes].flatMap((n) => (n.nodeType === 3 ? [n] : [...n.childNodes])).find((n) => n.data.includes('عائشہ'));
const left = (n, i) => { const r = document.createRange(); r.setStart(n, i); r.setEnd(n, i + 1); return [...r.getClientRects()].find((b) => b.width > 0).left; };
const three = left(node, node.data.indexOf('3'));
const at = nameNode.data.indexOf('عائشہ');
const name = Math.min(...[0, 1, 2, 3, 4].map((i) => left(nameNode, at + i)));
return three < name ? 'left of the name' : 'right of the name';
}
for (const p of document.querySelectorAll('#dashboard p')) console.log(`dashboard${p.querySelector('bdi') ? ' with bdi' : ''}: the count 3 is ${countSide(p)}`);
for (const card of document.querySelectorAll('.card:not(#dashboard)')) {
for (const p of card.querySelectorAll('p')) {
const text = p.dataset.check;
console.log(`${card.id}: "${text}" reads "${seen(p, text)}" from left to right`);
}
}
</script>
</body>
</html> | Text | Reads, left to right | Isolated with bdi or dir="ltr" |
|---|---|---|
AB-1234 in an Urdu sentence |
AB-1234 | AB-1234 |
36-KX in an Urdu sentence |
KX-36 | 36-KX |
+91 12345 67890 after an Urdu word |
67890 12345 91+ | +91 12345 67890 |
| An Urdu name before “: 3 orders” in English | the 3 lands left of the name | the 3 stays after the name |
The three engines drew all four lines the same way. AB-1234 survives because digits that follow a Latin letter count
as left-to-right text (rule W7), so the whole code is one left-to-right run. 36-KX does not: its digits follow Urdu
letters, so they become Arabic-type numbers (rule W2), the hyphen between them and KX belongs to neither side and takes
the paragraph’s right-to-left direction, and the two halves swap. The phone number breaks the same way, group by group.
The program below runs those rules on the same strings:
// A small part of the Unicode Bidirectional Algorithm (UAX #9): enough to show why a phone number or a product code
// changes order inside right-to-left text, and why isolating it helps. It knows the character types of these examples
// only (Latin and Urdu letters, digits, + and -, the colon and spaces) and has no explicit embeddings or brackets.
// Isolates are marked with U+2068 (first strong isolate, what <bdi> and dir="auto" do) and U+2069 (pop isolate).
const FSI = '\u2068', PDI = '\u2069';
/** The bidirectional type of one character. */
function typeOf(ch) {
if (ch === FSI || ch === PDI) return 'NI';
if (/[0-9]/.test(ch)) return 'EN';
if (/\p{Script=Arabic}/u.test(ch) && /\p{L}/u.test(ch)) return 'AL';
if (/\p{L}/u.test(ch)) return 'L';
if (ch === '+' || ch === '-') return 'ES';
if (ch === ':' || ch === ',' || ch === '.') return 'CS';
if (ch === ' ') return 'WS';
return 'ON';
}
const strongOf = (t) => (t === 'L' ? 'L' : t === 'R' || t === 'AL' ? 'R' : null);
/** The direction a first-strong isolate takes: its first L, R or AL character (rules P2 and P3). */
function firstStrong(chars) {
for (const ch of chars) {
const s = strongOf(typeOf(ch));
if (s) return s;
}
return 'L';
}
/** Resolves the levels of chars (one isolating run) at embedding level `level`; fills levels[from + i]. */
function resolve(chars, level, levels, from, trace = null) {
// Skip over isolates: each one counts as a single neutral here and is resolved on its own.
const units = [];
for (let i = 0; i < chars.length; i++) {
if (chars[i] === FSI) {
let depth = 1, j = i + 1; // find the matching U+2069
while (j < chars.length && depth > 0) {
if (chars[j] === FSI) depth++;
if (chars[j] === PDI) depth--;
j++;
}
const inner = chars.slice(i + 1, j - 1);
const dir = firstStrong(inner);
const innerLevel = dir === 'L' ? (level % 2 ? level + 1 : level + 2) : level % 2 ? level + 2 : level + 1;
resolve(inner, innerLevel, levels, from + i + 1);
units.push({ type: 'NI', at: [from + i, from + j - 1] });
i = j - 1;
} else units.push({ type: typeOf(chars[i]), at: [from + i] });
}
const t = units.map((u) => u.type);
const sos = level % 2 ? 'R' : 'L';
const before = (i) => { for (let k = i - 1; k >= 0; k--) if (['L', 'R', 'AL'].includes(t[k])) return t[k]; return sos; };
for (let i = 0; i < t.length; i++) if (t[i] === 'EN' && before(i) === 'AL') t[i] = 'AN'; // W2
for (let i = 0; i < t.length; i++) if (t[i] === 'AL') t[i] = 'R'; // W3
for (let i = 1; i < t.length - 1; i++) { // W4
if (t[i] === 'ES' && t[i - 1] === 'EN' && t[i + 1] === 'EN') t[i] = 'EN';
if (t[i] === 'CS' && t[i - 1] === t[i + 1] && (t[i - 1] === 'EN' || t[i - 1] === 'AN')) t[i] = t[i - 1];
}
for (let i = 0; i < t.length; i++) if (['ES', 'ET', 'CS'].includes(t[i])) t[i] = 'NI'; // W6 (no ET here, so no W5)
for (let i = 0; i < t.length; i++) if (['WS', 'ON'].includes(t[i])) t[i] = 'NI';
for (let i = 0; i < t.length; i++) if (t[i] === 'EN' && before(i) === 'L') t[i] = 'L'; // W7
if (trace) trace.w = t.map((x) => (x === 'NI' ? 'N' : x));
const strong = (x) => (x === 'L' ? 'L' : x === 'R' || x === 'EN' || x === 'AN' ? 'R' : null); // numbers count as R
for (let i = 0; i < t.length; i++) { // N1 and N2
if (t[i] !== 'NI') continue;
let j = i;
while (j < t.length && t[j] === 'NI') j++;
const a = i > 0 ? strong(t[i - 1]) : sos, b = j < t.length ? strong(t[j]) : sos;
for (let k = i; k < j; k++) t[k] = a === b ? a : sos;
i = j - 1;
}
if (trace) trace.n = [...t];
units.forEach((u, i) => { // I1 and I2: R goes up one level from an even level; L and numbers go up from an odd one
const x = t[i];
const even = level % 2 === 0;
let l = level;
if (even && x === 'R') l = level + 1;
else if (even && (x === 'AN' || x === 'EN')) l = level + 2;
else if (!even && (x === 'L' || x === 'EN' || x === 'AN')) l = level + 1;
for (const k of u.at) levels[k] = l;
});
}
/** The visual order (left to right) of the characters of `text` in a paragraph of direction `dir`. */
function visualOrder(text, dir) {
const chars = [...text];
const levels = new Array(chars.length);
resolve(chars, dir === 'rtl' ? 1 : 0, levels, 0);
const order = chars.map((_, i) => i);
for (let l = Math.max(...levels); l >= 1; l--) { // L2: reverse every run at level l or higher
for (let i = 0; i < order.length; i++) {
if (levels[order[i]] < l) continue;
let j = i;
while (j < order.length && levels[order[j]] >= l) j++;
order.splice(i, j - i, ...order.slice(i, j).reverse());
i = j;
}
}
return order.filter((i) => chars[i] !== FSI && chars[i] !== PDI).map((i) => ({ i, ch: chars[i] }));
}
/** How the characters of `piece` appear, left to right, when `text` is shown. */
function reads(text, dir, piece) {
const plain = text.replaceAll(FSI, '').replaceAll(PDI, '');
const start = [...plain].findIndex((_, i) => plain.slice(i).startsWith(piece));
const offset = (i) => [...text].slice(0, i).filter((c) => c !== FSI && c !== PDI).length; // index without marks
const visual = visualOrder(text, dir).map(({ i, ch }) => ({ at: offset(i), ch }));
return visual.filter((v) => v.at >= start && v.at < start + piece.length).map((v) => v.ch).join('');
}
const cases = [
['rtl', 'ماڈل AB-1234 کا آرڈر کل پہنچے گا', 'AB-1234', 'a code that starts with a letter'],
['rtl', 'رنگ کا کوڈ 36-KX ہے', '36-KX', 'a code that starts with digits'],
['rtl', `رنگ کا کوڈ ${FSI}36-KX${PDI} ہے`, '36-KX', 'the same code, isolated'],
['rtl', 'فون: +91 12345 67890', '+91 12345 67890', 'a phone number after Urdu text'],
['rtl', `فون: ${FSI}+91 12345 67890${PDI}`, '+91 12345 67890', 'the same number, isolated'],
];
for (const [dir, text, piece, what] of cases) console.log(`${dir}, ${what}: "${piece}" reads "${reads(text, dir, piece)}"`);
// An English line with an Urdu name: does the count stay after the name?
for (const [text, what] of [['Customer عائشہ: 3 orders', 'name as typed'], [`Customer ${FSI}عائشہ${PDI}: 3 orders`, 'name isolated']]) {
const v = visualOrder(text, 'ltr').map((x) => x.ch).join('');
const side = v.indexOf('3') < v.indexOf('ہ') ? 'left' : 'right';
console.log(`ltr, ${what}: the 3 is drawn ${side} of the name`);
}
// Why the phone number broke: the types, what rules W1-W7 (weak types) and N1-N2 (neutrals) make of them, the levels.
const text = [...'فون: +91 12345'];
const levels = new Array(text.length);
const trace = {};
resolve(text, 1, levels, 0, trace);
const cells = (list) => list.slice(5).map((x) => String(x).padEnd(4)).join('').trimEnd();
console.log('\n"+91 12345" after an Urdu word and a colon, in an rtl paragraph');
console.log(' character ' + cells(text.map((c) => (c === ' ' ? '_' : c))));
console.log(' type ' + cells(text.map(typeOf)));
console.log(' W1 to W7 ' + cells(trace.w));
console.log(' N1 and N2 ' + cells(trace.n));
console.log(' level ' + cells(levels)); Output
rtl, a code that starts with a letter: "AB-1234" reads "AB-1234" rtl, a code that starts with digits: "36-KX" reads "KX-36" rtl, the same code, isolated: "36-KX" reads "36-KX" rtl, a phone number after Urdu text: "+91 12345 67890" reads "67890 12345 91+" rtl, the same number, isolated: "+91 12345 67890" reads "+91 12345 67890" ltr, name as typed: the 3 is drawn left of the name ltr, name isolated: the 3 is drawn right of the name "+91 12345" after an Urdu word and a colon, in an rtl paragraph character + 9 1 _ 1 2 3 4 5 type ES EN EN WS EN EN EN EN EN W1 to W7 N AN AN N AN AN AN AN AN N1 and N2 R AN AN R AN AN AN AN AN level 1 2 2 1 2 2 2 2 2
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node bidi_order.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
Its output matches what the three engines drew, line for line. The trace at the end shows the phone number: every digit group becomes an Arabic-type number at level 2, while the plus sign and the space between the groups resolve to right to left at level 1, so when the line is reversed the groups come out last-to-first.
How the phone number of the order card is stored and drawn
Text description of the diagram
The diagram has three rows of boxes. Blue boxes are left-to-right runs, grey boxes are right-to-left text and neutral characters, and every row reads from left to right as the screen does.
- Stored, the order in which the text was typed: the Urdu word for phone, a colon, a plus sign, then 91, 12345 and 67890. Under each box is its embedding level after the bidirectional algorithm: 1 for the Urdu word, the colon and the plus sign, 2 for the three groups of digits.
- Drawn in a right-to-left paragraph: 67890, 12345, 91, the plus sign, the colon and the Urdu word. The line is reversed as a whole, and each group of digits keeps its own left-to-right order, so the groups come out in reverse order and the plus sign lands after them.
- Drawn with the number wrapped in an element with dir="ltr": one box holding +91 12345 67890, then the colon and the Urdu word. The isolated number is laid out on its own, left to right, and moves as one block.
The fix is the same in every case: isolate each piece whose direction may differ from the text around it. An isolated run is laid out on its own and then treated as one neutral character outside.
<bdi>isolates and finds its direction from its content, likedir="auto". Use it for anything users typed: names, titles, search terms.<span dir="ltr">isolates with a direction you know. Use it for phone numbers, product codes, email addresses, URLs and file names, which are always left to right.- Where you cannot add markup, as in a
titleattribute, a notification or a text file, wrap the piece in the Unicode characters U+2068 FIRST STRONG ISOLATE and U+2069 POP DIRECTIONAL ISOLATE. They do in plain text whatbdidoes in HTML.
<bdo> is not a fix: it overrides the algorithm and draws every character in the order you give, so <bdo dir="rtl">
turns Latin words backwards.
Vertical text with writing-mode
writing-mode sets the block flow: horizontal-tb (the default), vertical-rl and vertical-lr, where lines run from
top to bottom and stack from right to left or from left to right, and sideways-rl and sideways-lr, which lay out
horizontal scripts on their side. vertical-rl and vertical-lr are Baseline widely available; the two sideways
values are newly available. In vertical modes text-orientation decides how each character stands: mixed, the default,
keeps Chinese and Japanese characters upright and turns other scripts on their side; upright stands everything up;
sideways turns everything.
Layout turns with the text. A flex row follows the inline axis, so in a vertical-rl container all three engines
placed the items of a flex-direction: row from the top down, against the right edge; grid tracks and logical
properties turn the same way. An element whose writing mode differs from its parent’s also joins the layout
differently: an inline element becomes an inline-block, which all three engines reported for a vertical span, and a
block starts an independent formatting context, so the margins of its children no longer collapse with its own.
The most common use outside East Asian text is a label: the y-axis of a chart, a table header, a tab on the side of a
panel. Rotating it with transform is the old way, and it costs space:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>A vertical axis label: rotate() or writing-mode</title>
<style>
body { font: 16px/1.5 system-ui, sans-serif; margin: 16px; }
.chart { display: flex; align-items: center; gap: 8px; inline-size: 320px; block-size: 160px; margin-block-end: 16px; }
.plot { flex: 1; align-self: stretch; background: #e2e8f0; }
.label { font-size: 14px; white-space: nowrap; }
.rotated .label { transform: rotate(-90deg); } /* only the painting turns */
.vertical .label { writing-mode: vertical-rl; rotate: 180deg; } /* the box turns; read bottom to top */
.sideways .label { writing-mode: sideways-lr; } /* the same in one declaration */
.volume { writing-mode: vertical-lr; inline-size: 120px; } /* a vertical slider */
</style>
</head>
<body>
<div class="chart rotated" id="rotate"><span class="label">Rainfall (mm)</span><div class="plot"></div></div>
<div class="chart vertical" id="vertical-rl"><span class="label">Rainfall (mm)</span><div class="plot"></div></div>
<div class="chart sideways" id="sideways-lr"><span class="label">Rainfall (mm)</span><div class="plot"></div></div>
<input type="range" class="volume" id="volume" aria-label="Volume">
<script>
for (const chart of document.querySelectorAll('.chart')) {
const label = chart.querySelector('.label');
const painted = label.getBoundingClientRect();
const plot = chart.querySelector('.plot').getBoundingClientRect();
console.log(`${chart.id}: layout box ${label.offsetWidth} x ${label.offsetHeight}, painted ${Math.round(painted.width)} x ${Math.round(painted.height)}, plot ${Math.round(plot.width)} px wide`);
}
const volume = document.getElementById('volume');
const r = volume.getBoundingClientRect();
console.log(`range input with writing-mode: vertical-lr: ${Math.round(r.width)} x ${Math.round(r.height)}, computed writing-mode ${getComputedStyle(volume).writingMode}`);
</script>
</body>
</html> transform: rotate(-90deg): the label’s layout box stayed 85 × 21 px while it was painted 21 × 85 px, and the plot was 227 px wide.writing-mode: vertical-rlwithrotate: 180deg: a layout box of 21 × 85 px, and a plot 291 px wide.writing-mode: sideways-lr: the same 21 × 85 px box and 291 px plot.
A transform changes only how a box is painted, so the rotated label still took an 85 px wide slot in the row, and the
plot lost 64 px to empty space. With writing-mode the box itself is 21 px wide. vertical-rl runs the text from top
to bottom, so rotate: 180deg turns it to read upwards; sideways-lr does both in one declaration, but it is only
newly available, so keep the first form where older browsers matter. The three engines measured the same boxes.
Form controls take part too. writing-mode: vertical-lr on an <input type="range"> made a vertical slider, 120 px
tall and 16 px wide in Chromium and WebKit and 20 px wide in Firefox. The same Baseline feature, vertical form
controls, covers other controls such as progress bars, radio buttons and select menus, and it is newly available.
Choosing which end of a vertical slider is the minimum with direction works only in Chromium so far, so don’t depend
on it yet.
Upright text, combined digits and Indian scripts
Vertical signs, book spines and some posters stack characters upright. text-combine-upright: all fits a short run,
such as a two-digit number, into the space of one character. In a vertical Hindi sign set at 36 px, the number 12
wrapped in a span with that property took 36 px of the column, one cell, in all three engines; without it the two
digits took 57 to 102 px depending on the engine. CSS Writing Modes also defines a digits value that would find the
numbers by itself, but none of the three engines accepts it yet, so wrap the digits in an element.
Upright text is where Indian scripts need care, and the W3C’s Indic Layout Requirements say why: in vertical text the unit to stack is the orthographic syllable, a consonant cluster with its vowel sign, such as स्वा in स्वागतम् (“welcome”). The engines don’t do that:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>A vertical Hindi sign: three ways</title>
<style>
/* Self-hosted Noto fonts (SIL Open Font License 1.1) from notofonts.github.io, served from /fonts/. */
@font-face { font-family: "Noto Sans Devanagari"; src: url("/fonts/NotoSansDevanagari-Regular.ttf"); }
body { font: 16px/1.5 system-ui, sans-serif; margin: 16px; display: flex; gap: 32px; align-items: start; }
.sign { font: 36px/1.3 "Noto Sans Devanagari", sans-serif; margin: 0; }
.upright { writing-mode: vertical-rl; text-orientation: upright; } /* each character upright */
.turned { writing-mode: vertical-rl; } /* the whole word turned sideways */
.stack { display: flex; flex-direction: column; align-items: center; } /* one syllable per line */
.digits { text-combine-upright: all; }
.tag { writing-mode: vertical-rl; }
</style>
</head>
<body>
<p class="sign upright" lang="hi" id="upright">स्वागतम्</p>
<p class="sign turned" lang="hi" id="turned">स्वागतम्</p>
<p class="sign stack" lang="hi" id="stack"><span>स्वा</span><span>ग</span><span>त</span><span>म्</span></p>
<p class="sign upright" lang="hi" id="gate">द्वार <span class="digits">12</span></p>
<p class="sign upright" lang="hi" id="gate-plain">द्वार <span>12</span></p>
<p lang="en">A <span class="tag" id="tag">tag</span> in a line.</p>
<script>
for (const id of ['gate', 'gate-plain']) {
const digits = document.querySelector(`#${id} span`).getBoundingClientRect();
console.log(`${id}: "12" takes ${Math.round(digits.height)} px of the column at a font size of 36 px`);
}
console.log(`tag: an inline span with its own writing-mode computes to display: ${getComputedStyle(document.getElementById('tag')).display}`);
</script>
</body>
</html> - With
text-orientation: upright, all three engines stacked स्वागतम् in six cells instead of four syllables: स्, व, the vowel sign ा on its own, ग, त and म्. WebKit also drew the final virama beside its letter instead of under it. - With the default
mixedorientation the word turns on its side as a whole, and every syllable keeps its shape. - A column of spans, one per syllable, in a flex container gave the four upright syllables the requirements describe.
So keep Indian scripts horizontal, or turn them whole with the default orientation; for an upright sign, split the syllables yourself.
Practise: predict dir=“auto”
Exercise · Easy · JavaScript
Work out the direction dir="auto" picks
dir="auto" gives an element the direction of the first strong character in its text: a letter of a left-to-right or a right-to-left script. Digits, punctuation, spaces, emoji and combining marks are not strong, so they are skipped. Write two functions in auto_direction.mjs and export them.
autoDirection(text) returns 'rtl' when the first strong character is right to left, 'ltr' when it is left to right, and null when the text has no strong character at all. For this exercise:
- letters (Unicode general category L, /\p{L}/u) of the Arabic, Hebrew, Syriac, Thaana and N'Ko scripts are right to left; every other letter, Latin, Devanagari and Tamil among them, is left to right; - the invisible marks U+200E LEFT-TO-RIGHT MARK and U+200F RIGHT-TO-LEFT MARK count as strong left-to-right and right-to-left characters, and so does U+061C ARABIC LETTER MARK (right to left); - Arabic-Indic digits such as ١٢٣ are numbers, not letters, so they are skipped like other digits.
elementDirection(text) returns the direction an element with dir="auto" ends up with: the result of autoDirection, or 'ltr' when that is null, which is what the HTML Standard says a browser does.
The sample tests import both functions from auto_direction.mjs and run in your browser.
Starter code · auto_direction.mjs
/** The direction dir="auto" gives a piece of text: its first strong character decides, or null when it has none. */
export function autoDirection(text) {
// Replace this line with your code.
return 'ltr';
}
/** What the element ends up with: the text's direction, or ltr when the text has no strong character. */
export function elementDirection(text) {
// Replace this line with your code.
return 'ltr';
} The sample tests · auto_direction.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { autoDirection, elementDirection } from './auto_direction.mjs';
test('the first letter decides', () => {
assert.equal(autoDirection('Fast delivery, well packed.'), 'ltr');
assert.equal(autoDirection('ڈلیوری تیز تھی'), 'rtl');
assert.equal(autoDirection('שלום'), 'rtl');
assert.equal(autoDirection('नमस्ते दुनिया'), 'ltr');
});
test('digits, punctuation and emoji are skipped', () => {
assert.equal(autoDirection('5 ستارے، بہت اچھا!'), 'rtl');
assert.equal(autoDirection('#42 – Order shipped'), 'ltr');
assert.equal(autoDirection('😀 ١٢٣ بہت'), 'rtl');
});
test('an English brand name first makes Urdu text ltr', () => {
assert.equal(autoDirection('Samsung کا یہ فون بہت اچھا ہے۔'), 'ltr');
});
test('direction marks count as strong characters', () => {
assert.equal(autoDirection('\u200FSamsung کا یہ فون'), 'rtl');
assert.equal(autoDirection('\u200Eبہت اچھا'), 'ltr');
});
test('text without a strong character', () => {
assert.equal(autoDirection('!!! 😀'), null);
assert.equal(autoDirection(''), null);
assert.equal(autoDirection('١٢٣ 456'), null);
assert.equal(elementDirection('!!! 😀'), 'ltr');
assert.equal(elementDirection('بہت'), 'rtl');
}); A hint
Loop over the text with for (const ch of text), which walks code points rather than UTF-16 units. Skip every character that is not a letter (/\p{L}/u), apart from the three marks. The first letter you reach decides: test it against the right-to-left scripts with Unicode script escapes such as \p{Script=Arabic}.
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 does dir=“auto” do, and when should you use it?
dir="auto" gives an element the direction of the first strong character in its text, that is, the first letter of a
left-to-right or right-to-left script, skipping digits, punctuation, spaces and emoji. If there is none, the element is
left to right. It also isolates the element from the text around it. Use it for content whose direction you don’t
know in advance: user names, reviews, chat messages, search boxes and textareas, where it switches as the user types.
It is only a guess: an Urdu sentence that starts with an English brand name comes out left to right. When the direction
is known, from the language the user picked or from the dirname field a form sends, set dir="rtl" or dir="ltr"
explicitly, and on the html element always set it explicitly.
Key takeaways
- Set direction with the
dirattribute:ltrorrtlonhtml, andautoonly for text whose direction you don’t know; avoid thedirectionandunicode-bidiproperties in HTML. dir="auto"follows the first letter of the text, so text that starts with a word in the other direction comes out wrong; store the direction withdirnamewhen you can.- Digits, signs and punctuation take their direction from their neighbours, so codes and phone numbers in
right-to-left text can reorder; isolate them with
<span dir="ltr">, and user text with<bdi>. writing-modeturns the box,transformonly its painting: usevertical-rlwithrotate: 180deg, orsideways-lr, for vertical labels.- An element with its own writing mode becomes an inline-block or a new formatting context; flex and grid follow the writing mode.
text-combine-upright: allfits short numbers into one upright cell; upright Indian scripts break their syllables, so turn them whole or stack the syllables yourself.
References
- CSS Writing Modes Level 4 (W3C CSS Working Group)
- CSS Writing Modes Level 4: text-combine-upright (W3C CSS Working Group)
- HTML Standard: the dir attribute (WHATWG)
- HTML Standard: the bdi element (WHATWG)
- HTML Standard: rendering of bidirectional text (WHATWG)
- HTML Standard: the dirname attribute (WHATWG)
- Unicode Bidirectional Algorithm (UAX #9) (Unicode Consortium)
- Selectors Level 4: the directionality pseudo-class :dir() (W3C CSS Working Group)
- CSS Flexible Box Layout Module Level 1: flex-direction (W3C CSS Working Group)
- Indic Layout Requirements: vertical text (W3C)
- Structural markup and right-to-left text in HTML (W3C Internationalization)
- writing-mode (Baseline, web-features explorer) (W3C WebDX Community Group)
- Vertical form controls (Baseline, web-features explorer) (W3C WebDX Community Group)
- text-combine-upright (Baseline, web-features explorer) (W3C WebDX Community Group)
- CSS writing modes (MDN Web Docs) (Mozilla)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress