How to test rtl layout

Set document.documentElement.dir = 'rtl' in the Console, then read getBoundingClientRect() and the computed styles before and after. On the fixture below, a badge styled with margin-left: 48px held its margin on the left and the gap to its label collapsed from 48 px to 0 px, while the logical-property badge mirrored.

Why check this

RTL support is a layout property, not a translation property. A build can ship every Arabic and Hebrew string and still place the back arrow, the drawer and the progress bar on the wrong side, because the CSS names sides in physical terms. Run this on staging once the locale files land, and again after any change to shared layout CSS.

The failure it catches is a control that stops being reachable. A physical margin-left that was separating a badge from its label puts that space on the outside under RTL and lets the two boxes touch, so text runs into text. A screenshot review in the source language never sees it.

Prerequisites

<!doctype html>
<html lang="en" dir="ltr">
<meta charset="utf-8">
<title>RTL fixture</title>
<style>
 body{font:16px "Segoe UI",system-ui,sans-serif;margin:0;padding:16px}
 .row{display:flex;align-items:center;width:420px;border:1px solid #999;padding:8px;margin:0 0 12px}
 .tag{background:#eee;padding:2px 6px}
 #physical{margin-left:48px}             /* defect: a physical margin never mirrors */
 #logical{margin-inline-start:48px}      /* control: a logical margin mirrors */
 .icon{display:inline-block;width:28px;text-align:center}
 [dir=rtl] .icon{transform:scaleX(-1)}   /* defect: mirrors every icon, logo included */
 #aligned{display:block;direction:ltr;text-align:right} /* defect: aligned, not mirrored */
 #bidi{font-size:20px}
</style>
<div class="row"><span>Label</span><span class="tag" id="physical">physical</span></div>
<div class="row"><span>Label</span><span class="tag" id="logical">logical</span></div>
<div class="row" id="icons"><span class="icon" id="play">▶</span><span class="icon" id="clock">◔</span><span class="icon" id="logo">F</span></div>
<div class="row" id="mirrored"><span class="tag">1</span><span class="tag">2</span><span class="tag">3</span></div>
<div class="row" id="aligned"><span class="tag">1</span><span class="tag">2</span><span class="tag">3</span></div>
<p id="bidi">الرصيد 42 USD متاح</p>
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
const PORT = 8613;
createServer((req, res) => {
  const name = req.url.split('?')[0].replace(/^\//, '') || 'index.html';
  let body;
  try {
    body = readFileSync(new URL(name, import.meta.url));
  } catch {
    res.writeHead(404).end('not found');
    return;
  }
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
  res.end(body);
}).listen(PORT, '127.0.0.1', () => console.log(`http://127.0.0.1:${PORT}/`));

Steps

  1. Step 1.

    Open http://127.0.0.1:8613/rtl.html, open DevTools, Console tab, and paste this. It flips the document in both directions and reports the resolved margins with the gap between each badge and its label.

    const gap = (id) => {
      const tag = document.getElementById(id), label = tag.previousElementSibling;
      const r = (el) => el.getBoundingClientRect(), cs = getComputedStyle(tag);
      const d = r(tag).left > r(label).right
        ? r(tag).left - r(label).right
        : r(label).left - r(tag).right;
      return `  ${id.padEnd(9)} margin-left ${cs.marginLeft.padEnd(5)} margin-right ${cs.marginRight.padEnd(5)} gap ${d.toFixed(1)}px`;
    };
    ['ltr', 'rtl'].map((d) => {
      document.documentElement.dir = d;
      return `dir=${d}\n` + [gap('physical'), gap('logical')].join('\n');
    }).join('\n');
    
    dir=ltr
    physical  margin-left 48px  margin-right 0px   gap 48.0px
    logical   margin-left 48px  margin-right 0px   gap 48.0px
    dir=rtl
    physical  margin-left 48px  margin-right 0px   gap 0.0px
    logical   margin-left 0px   margin-right 48px  gap 48.0px

    Both badges look identical under dir=ltr. Under dir=rtl the logical badge moved its 48 px to margin-right and kept the separation. The physical badge kept margin-left, and the gap that was doing the work went to 0 px.

  2. Step 2.

    Read the computed transform on the three icons while the document is RTL.

    document.documentElement.dir = 'rtl';
    ['play', 'clock', 'logo']
      .map((id) => `${id.padEnd(6)} ${getComputedStyle(document.getElementById(id)).transform}`)
      .join('\n');
    
    play   matrix(-1, 0, 0, 1, 0, 0)
    clock  matrix(-1, 0, 0, 1, 0, 0)
    logo   matrix(-1, 0, 0, 1, 0, 0)

    matrix(-1, 0, 0, 1, 0, 0) is a horizontal flip. A play arrow points along the reading direction, so it belongs in that list. A clock face and a brand mark do not, and the selector caught all three.

  3. Step 3.

    Separate a row that mirrors from a row that is only pushed to the right. Both rows hold the same three boxes.

    document.documentElement.dir = 'rtl';
    const order = (id) => id.padEnd(9) + [...document.getElementById(id).children]
      .map((c) => `${c.textContent}@${Math.round(c.getBoundingClientRect().left)}`).join('  ');
    [order('mirrored'), order('aligned')].join('\n');
    
    mirrored 1@1234  2@1214  3@1193
    aligned  1@1193  2@1214  3@1234

    The two rows occupy the same three x positions: 1193, 1214 and 1234. Only the assignment differs. The mirrored row starts its sequence at 1234 and reads to the left, the aligned row still reads to the right.

  4. Step 4.

    Compare the order the characters are stored in with the order they are painted in.

    document.documentElement.dir = 'rtl';
    const node = document.getElementById('bidi').firstChild, t = node.data, cells = [];
    for (let i = 0; i < t.length; i++) {
      const rg = document.createRange();
      rg.setStart(node, i); rg.setEnd(node, i + 1);
      const b = rg.getBoundingClientRect();
      if (b.width) cells.push({ i, x: b.left });
    }
    const leftmost = (p) => Math.round(Math.min(...cells.filter(p).map((c) => c.x)));
    `string   ${t}
    logical  ${cells.map((c) => c.i).join(' ')}
    painted  ${[...cells].sort((a, b) => a.x - b.x).map((c) => c.i).join(' ')}
    slice(0,9) "${t.slice(0, 9)}" leftmost x ${leftmost((c) => c.i < 9)}
    slice(9)   "${t.slice(9)}" leftmost x ${leftmost((c) => c.i >= 9)}`;
    
    string   الرصيد 42 USD متاح
    logical  0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
    painted  17 16 15 14 13 10 11 12 9 7 8 6 5 4 3 2 1 0
    slice(0,9) "الرصيد 42" leftmost x 1182
    slice(9)   " USD متاح" leftmost x 1097

    The painted order runs backwards except at two places. Indexes 10, 11, 12 (USD) and 7, 8 (42) stay ascending, because a Latin run and a digit run keep their own direction inside an RTL paragraph. The last two lines are the consequence: the first nine characters are painted 85 px to the right of the rest, so "the first half" and "the left half" are different halves.

  5. Step 5.

    List the rules that can break under RTL, by comparing the two sides of every pair rather than matching property names.

    const hits = [];
    for (const sheet of document.styleSheets) {
      let rules;
      try { rules = sheet.cssRules; } catch { continue; }
      for (const rule of rules) {
        if (!rule.style) continue;
        for (const base of ['margin', 'padding', 'border-width', 'border-style', 'border-color']) {
          const [a, b] = base.startsWith('border-')
            ? [`border-left-${base.slice(7)}`, `border-right-${base.slice(7)}`]
            : [`${base}-left`, `${base}-right`];
          const l = rule.style.getPropertyValue(a), r = rule.style.getPropertyValue(b);
          if (l && r && l !== r) hits.push(`${rule.selectorText} { ${a}: ${l}; ${b}: ${r} }`);
          else if (Boolean(l) !== Boolean(r)) hits.push(`${rule.selectorText} { ${l ? a + ': ' + l : b + ': ' + r} }`);
        }
        for (const p of ['text-align', 'direction', 'left', 'right', 'float', 'clear']) {
          const v = rule.style.getPropertyValue(p);
          if (v && v !== 'center') hits.push(`${rule.selectorText} { ${p}: ${v} }`);
        }
      }
    }
    `asymmetric physical rules: ${hits.length}\n` + hits.map((h) => '  ' + h).join('\n');
    
    asymmetric physical rules: 3
    #physical { margin-left: 48px }
    #aligned { text-align: right }
    #aligned { direction: ltr }

    Three rules, and all three are the planted defects. The scan runs before the page is flipped, so it works as a pre-check on any stylesheet the browser can read.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | A gap that is 48 px under LTR and 0 px under RTL | A physical margin or padding carries the spacing | Replace it with margin-inline-start or padding-inline-start. | | matrix(-1, 0, 0, 1, 0, 0) on a logo or a clock | A selector mirrors icons by class, not by meaning | Mirror the directional icons by name and leave the rest alone. | | Both rows at the same x values in opposite order | One row mirrors, the other is right-aligned | Set dir on the container instead of text-align: right. | | Logical indexes ascending where painted indexes descend | A bidirectional run reordered | Stop indexing the string by position for anything the reader sees. | | The scan returns 0 rules and the layout still breaks | The spacing comes from inline styles or a shadow root | document.styleSheets misses both. Check them separately. |

Common mistakes

Sign: The RTL screenshot looks correct, and the order of the controls is still wrong.Cause: A container with text-align: right and direction: ltr paints its children at the same pixel columns as a mirrored container, in the opposite order. In step 3 both rows used x 1193, 1214 and 1234. An image diff of the two is empty at the box level, so this survives visual regression and reaches a reader who follows the boxes in the wrong order.
Sign: Icons that carry no direction come out backwards under RTL.Cause: A selector such as [dir=rtl] .icon { transform: scaleX(-1) } mirrors by class. A play arrow and a next chevron follow the reading direction; a clock face, a brand mark, a photograph and a checkmark do not. Chrome reports every one of them as matrix(-1, 0, 0, 1, 0, 0), so the computed transform is the fastest way to list what the rule caught.
Sign: A scan for physical CSS properties returns a long list and almost none of it matters.Cause: The CSSOM expands shorthands, so margin: 0 becomes margin-left and margin-right and both show up in a property-name scan. On this fixture that scan returned 11 rules where 1 was a defect, and it missed text-align: right and direction: ltr because neither name ends in left or right. Comparing the values on the two sides cut the same fixture to 3.
Sign: The page is translated into Arabic and nothing mirrors.Cause: Mirroring follows the dir attribute, not the lang attribute. The fixture above stays on lang=en through every step and mirrors as soon as dir is set. A build that ships Arabic strings without setting dir on the html element renders an LTR layout holding RTL text.

What to check next

FAQ

How do I test RTL in Chrome without an RTL build?

Set document.documentElement.dir = 'rtl' in the Console, or edit the dir attribute on the html element in the Elements panel. Both apply the mirroring rules the real locale would. Neither changes the strings, which is what you want while looking at layout.

How do I run an RTL test on a page in CI?

Flip dir in the page, then assert on geometry rather than on an image. The comparisons in steps 1 and 3 catch most of it: spacing that collapses, and children that keep their order.

Is lang="ar" enough to mirror the page?

No. The fixture here stays on lang="en" and mirrors on dir alone. lang drives font selection, hyphenation and what assistive technology announces. Direction comes from the dir attribute or the direction property.

Do logical properties handle everything?

They handle the box model, and the browser does the bidi reordering for text. They do not decide which icons carry a direction, and they do not fix a container pinned with left or float.

Verified

Verified by Maks VernyChrome 152.0.7977.76Node 22.23.2

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate9 minpublished updated Maks Verny