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.
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.
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.
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.
Related
- RTL & Bidirectional Layout Engineering — the direction model these properties resolve against.
- Fixing mirrored icons and logical CSS properties — the per-icon decision that follows the layout fix.
- Screenshot diffing RTL layouts in CI — proving the fix holds across whole pages.
- Text expansion clipping in fixed-width buttons — the other geometry defect found in the same sweep.