RTL flexbox and grid order bugs

The stylesheet was migrated to logical properties, dir="rtl" is set on the root, and the toolbar still renders back to front in Arabic: the primary action sits where the cancel button should be, and the icon is on the wrong side of its label. Nothing in the component uses margin-left any more.

Flexbox and grid mirror themselves under a direction change — that is the good news and the source of the confusion, because a layout that was already reversed manually now gets reversed twice.

Which layout properties respond to direction Flex direction, main-axis alignment, the order property and grid column placement all resolve against the writing direction and mirror automatically under a right-to-left root. Absolute positioning with physical offsets and translate transforms are geometric rather than logical and do not mirror. What mirrors automatically, and what does not Under dir=rtl flex-direction: row mirrors justify-content: flex-start mirrors order: 2 mirrors — order is logical grid-column: 1 / 3 mirrors — tracks are logical position: absolute; left: 0 does NOT mirror transform: translateX(8px) does NOT mirror
The first four are free; the last two are where a mirrored layout actually breaks.

Root cause: the flex axis is already logical

flex-direction: row does not mean “left to right”. It means “along the inline axis in the current writing direction”, which is left-to-right in English and right-to-left in Arabic. The same is true of justify-content: flex-start, of the order property, and of grid column placement: all of them are defined against the writing mode, and all of them mirror for free.

That is why a correctly built flex layout needs no work at all to mirror. And it is why a layout that used row-reverse to achieve a visual arrangement breaks: under a right-to-left root, row-reverse reverses the already-reversed axis and the components render back to front.

The same reasoning explains order. A component that assigns order: 2 to move an element visually is expressing a position along a logical axis, so the visual result flips with direction — which is usually right, and is a surprise if the author was thinking in physical terms.

row-reverse compared with a direction change Reversing a row with row-reverse flips it in every direction, so a right-to-left layout ends up reversed twice and reads back to front, while the DOM order stops matching what is seen. Letting direction decide the main axis keeps one rule correct in both directions and preserves the order assistive technology follows. Two ways to reverse a row flex-direction: row-reverse · Reverses in BOTH directions · RTL then renders it back to front · DOM order no longer matches visual order · Screen reader order diverges dir on the container · Direction decides the axis · One rule serves both directions · DOM order stays meaningful · Assistive technology follows row-reverse is a visual hack; direction is a semantic statement. Only the second survives mirroring.
If a layout needs row-reverse to look right in one direction, it will look wrong in the other.

The two properties that genuinely do not mirror

Two categories remain physical no matter what the direction is, and they account for nearly every remaining defect after a logical-property migration.

Absolute positioning with physical offsets. left: 0 means the left edge, always. A badge pinned to the top-left of a card stays top-left in Arabic, where it should be top-right. The replacement is inset-inline-start: 0, which resolves to the correct edge in each direction.

Transforms. translateX(8px) moves eight pixels toward the right in every writing mode, because transforms operate in a geometric coordinate space rather than in a logical one. A slide-in panel animating from translateX(-100%) slides in from the wrong side under a right-to-left root. There is no logical equivalent, so these need an explicit override.

.drawer { transform: translateX(-100%); }
[dir="rtl"] .drawer { transform: translateX(100%); }

/* Or, where the meaning of the shape itself flips */
[dir="rtl"] .icon-chevron { transform: scaleX(-1); }

Scroll offsets belong in the same category in spirit: scrollLeft is a physical value whose sign convention differs across engines in right-to-left contexts, which is one of the few genuine cross-browser inconsistencies left in this area.

Minimal reproducible example

<div class="toolbar">
  <button class="cancel">Cancel</button>
  <button class="primary">Save</button>
</div>

<style>
  /* Author wanted Save on the left in English, so reversed the row. */
  .toolbar { display: flex; flex-direction: row-reverse; gap: 8px; }
</style>

In English this puts Save on the left, as intended. Under dir="rtl" the axis is already reversed, so row-reverse reverses it back and Save lands where Cancel should be — in the position a reader scanning right to left reaches last.

The fix: express the order in the DOM, not in a reversal

<!-- Put the elements in the order they should be read. -->
<div class="toolbar">
  <button class="primary">Save</button>
  <button class="cancel">Cancel</button>
</div>

<style>
  .toolbar { display: flex; gap: 8px; }        /* no reversal at all */
</style>

The rule that generalises: the DOM order is the reading order, and the layout follows the writing direction. When those two agree, mirroring is free and assistive technology — which follows the DOM — announces what a sighted reader sees.

A row-reverse in a codebase is worth treating as a smell rather than an error. Occasionally it is right, when a visual arrangement genuinely differs from the reading order. Far more often it is compensating for a DOM order that was chosen for markup convenience rather than for meaning.

Five steps for auditing a component under both directions Searching for physical offsets finds the properties that cannot mirror. Replacing them with logical equivalents fixes most of them. Every use of a reverse value is examined, since it usually indicates a container in the wrong direction. Transforms whose meaning flips are mirrored explicitly under a right-to-left selector. And a screenshot in both directions is the only proof that the result is correct. Auditing a component for direction safety 1 Search for physical offsets left:, right:, translateX, margin-left 2 Replace with logical equivalents inset-inline-start, margin-inline-start 3 Check every reverse row-reverse usually means the wrong container 4 Mirror transforms explicitly scaleX(-1) under [dir=rtl] where meaning flips 5 Screenshot both directions the only proof that holds
Steps one and two fix most components; step five is what tells you which ones they did not.

Grid: tracks are logical, placement can be physical

Grid behaves the same way with one extra trap. Column tracks are laid out along the inline axis, so grid-template-columns: 200px 1fr puts the fixed track on the right in Arabic — correct, and occasionally surprising.

What does not mirror is placement by line number when the author was thinking in physical terms. grid-column: 1 / 3 refers to logical lines and mirrors correctly; a grid-template-areas map, however, is a visual picture, and the strings are read left to right regardless of direction. An areas map designed for English produces a mirrored-but-wrong arrangement in Arabic, because the picture itself encodes a physical layout.

Where an areas map must differ, override it under the direction selector — the same explicit treatment transforms need. Where it does not, prefer line-based or auto placement, which stay logical.

Why the DOM order rule matters beyond direction

Fixing a reversed toolbar by reordering the markup rather than reversing the layout has benefits that outlast the right-to-left work, and they are worth naming because they change how the rule feels — from a constraint imposed by localization to a general improvement.

Keyboard order follows the DOM. A reader tabbing through a toolbar visits elements in source order regardless of what the layout did visually, so a reversed layout produces a focus order that jumps backwards across the screen. That is disorienting in any language and is a recognised accessibility failure, not merely an inconvenience.

Screen readers follow the DOM too. A visually reversed row is announced in the original order, so what is heard and what is seen disagree. For a form where order carries meaning — Cancel before Save, or the reverse — that disagreement is material.

Sequential navigation on mobile follows the DOM. Swipe navigation in mobile screen readers walks the accessibility tree, which mirrors source order, so the same divergence appears on touch devices where there is no visible focus ring to explain it.

The pattern to take from this is that a visual reversal is a claim that the reading order and the visual order legitimately differ. Sometimes that claim is true, and when it is, the correct implementation says so explicitly — with the elements in reading order and the visual arrangement handled by layout properties that carry no semantics.

Most of the time the claim is not true, and the reversal was added because the markup happened to be in an inconvenient order. Changing the markup costs one line and fixes the direction bug, the focus order and the announced order at once.

Verification

Assert on rendered geometry rather than on class names, in both directions.

test.each([['ltr', 'left'], ['rtl', 'right']])('%s puts Save at the %s', async (dir, side) => {
  await page.goto(`/${dir === 'rtl' ? 'ar' : 'en'}/settings`);
  const save   = await page.locator('.primary').boundingBox();
  const cancel = await page.locator('.cancel').boundingBox();
  expect(side === 'left' ? save!.x < cancel!.x : save!.x > cancel!.x).toBe(true);
});

That assertion fails on the row-reverse version in exactly one direction, which is the signature of a direction bug and the reason to run the suite in both — the screenshot sweep in screenshot diffing RTL layouts in CI generalises it to whole pages.

When to escalate

If a layout is correct but text within it reads oddly — a Latin product name appearing at the wrong end of an Arabic sentence — the issue is bidirectional isolation rather than layout, and it belongs to RTL and bidirectional layout engineering.

If icons still point the wrong way after the layout is fixed, that is a separate decision per icon, covered in fixing mirrored icons and logical CSS properties.

If a third-party component ignores direction entirely, it is drawing in a coordinate space you do not control. Wrapping it and mirroring the wrapper with scaleX(-1) works for purely visual widgets and is wrong for anything containing text, which would then render mirrored.

FAQ

Does row-reverse ever have a legitimate use?

Yes — when the visual order genuinely differs from the reading order in both directions, such as a chat log rendered bottom-up. The test is whether reversing is about meaning or about appearance; if it is appearance, the DOM order is what should change.

Do logical properties work in every browser I care about?

The inline and block logical properties have been broadly supported for years, including inset-inline-start. The pieces to check are the newer shorthand forms and anything involving writing modes other than horizontal — vertical Japanese layout is a different and much smaller problem space.

What about direction on individual elements?

Setting it per element is occasionally necessary — an input that must accept a Latin identifier inside an Arabic form — but it should be rare. Direction propagates from the root, and per-element overrides that are not about content genuinely being in another language usually indicate a layout being fixed in the wrong place.

How does this interact with scroll containers?

Horizontal scroll containers are the least consistent area: scrollLeft conventions differ across engines in right-to-left contexts, and scrollIntoView behaviour varies with them. Where a component depends on scroll position, test it in both directions specifically rather than assuming the layout fix covered it.

Part of RTL & Bidirectional Layout Engineering.