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

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.

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

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:

  • ltr and rtl set the direction of the element and everything inside it. The browser’s own stylesheet also gives every element with a dir attribute unicode-bidi: isolate, so its content is laid out on its own and cannot disturb the text around it.
  • auto looks 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 gets ltr.

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:

Five reviews with dir="auto" HTML · reviews.html
<!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.

Unicode Character Inspector Paste text copied from an app to see the invisible direction marks and isolates hidden in it.

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:

An Urdu order card with English codes HTML · order-card.html
<!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:

The bidirectional algorithm on the order card JavaScript · bidi_order.mjs
// 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

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.

A phone number after an Urdu word: stored order with levels, the order drawn in right-to-left text, and the order once isolated.Stored: the order it was typed inUrdu word1:1+1912123452678902Drawn in an rtl paragraph678901234591+:Urdu wordDrawn with the number in dir="ltr"+91 12345 67890:Urdu wordNumbers under the stored row are embedding levels.Blue: left-to-right runs (level 2). Grey: right-to-lefttext and neutrals (level 1). Rows read left to right.

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.

  1. 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.
  2. 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.
  3. 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, like dir="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 title attribute, 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 what bdi does 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:

An axis label: rotate() or writing-mode HTML · axis-label.html
<!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-rl with rotate: 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:

A vertical Hindi sign, three ways HTML · vertical-sign.html
<!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 mixed orientation 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}.

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 How should an Urdu page set its direction?

    Choose one answer.

    Show the answer to question 1

    Answer: <html lang="ur" dir="rtl">

    The HTML Standard asks authors to set direction with the dir attribute, so the page still reads correctly without its styles, and CSS Writing Modes says not to use direction or unicode-bidi in HTML documents. lang names the language but does not set the direction.

  2. Question 2 of 6 A review is shown with dir="auto". It starts "Samsung", then continues in Urdu. Which direction does it get?

    Choose one answer.

    Show the answer to question 2

    Answer: Left to right, because the first strong character is a Latin letter

    dir="auto" looks only for the first strong character, a letter of a left-to-right or right-to-left script, and the S of Samsung is one. All three engines laid this review out left to right. When the direction is known, set it explicitly or store it with the text through the dirname attribute.

  3. Question 3 of 6 In an Urdu paragraph, "+91 12345 67890" after the word for phone is drawn as 67890 12345 91+. Which markup fixes it?

    Choose one answer.

    Show the answer to question 3

    Answer: <span dir="ltr">+91 12345 67890</span>

    The digit groups follow Urdu letters, so they become Arabic-type numbers, and the plus sign and spaces take the paragraph's right-to-left direction, which reverses the order of the groups. An element with dir="ltr" isolates the number: it is laid out on its own, left to right, and moves as one block. Changing the whole paragraph to left to right would break the Urdu text.

  4. Question 4 of 6 Why did the code AB-1234 keep its order in the Urdu sentence while 36-KX turned into KX-36?

    Choose one answer.

    Show the answer to question 4

    Answer: Digits after a Latin letter count as left-to-right text, but digits after Urdu letters become Arabic-type numbers

    Rule W7 of the bidirectional algorithm turns European digits that follow a left-to-right letter into left-to-right characters, so AB-1234 is one left-to-right run. In 36-KX the digits follow Urdu letters, rule W2 makes them Arabic-type numbers, and the neutral hyphen between them and KX takes the paragraph's right-to-left direction, so the halves swap.

  5. Question 5 of 6 A 14px label that is 85px wide and 21px tall gets transform: rotate(-90deg) in a flex row. How wide is the slot it still takes in the row, in pixels?

    Type a number.

    Show the answer to question 5

    Answer: 85 px

    A transform changes only how the box is painted, not its layout box, so the row still reserves 85px for it even though the painted label is 21px wide. With writing-mode: vertical-rl (and rotate: 180deg to read upwards) or sideways-lr, the layout box itself becomes 21px wide, which in the lesson gave the plot 64px more room.

  6. Question 6 of 6 A vertical sign spells the Hindi word for welcome, four syllables long, with writing-mode: vertical-rl; text-orientation: upright. What did all three engines draw?

    Choose one answer.

    Show the answer to question 6

    Answer: Six upright cells, with a vowel sign cut off from its consonant

    Upright text is stacked in units that split the first syllable, a conjunct with a vowel sign, so the vowel sign ended up alone in a cell. The Indic Layout Requirements ask for one cell per orthographic syllable, which a column of syllable spans gives you; the default mixed orientation turns the whole word sideways with its shapes intact.

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 dir attribute: ltr or rtl on html, and auto only for text whose direction you don’t know; avoid the direction and unicode-bidi properties 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 with dirname when 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-mode turns the box, transform only its painting: use vertical-rl with rotate: 180deg, or sideways-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: all fits short numbers into one upright cell; upright Indian scripts break their syllables, so turn them whole or stack the syllables yourself.

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.