Most teams treat right-to-left as a rendering setting. Set dir="rtl", let the
browser mirror the layout, ship it. The page does flip, and at a glance it looks
finished — which is exactly the problem. What breaks afterwards breaks quietly,
in places nobody thinks to check, and usually in the parts a visitor touches
most.
Everything below is a specific failure, not a principle. Each one has cost us time, and each one is testable in a browser in under a minute.
Physical properties do not mirror. Logical ones do.
margin-left means left in every language. It has no opinion about reading
direction, so a layout built from left, right, padding-left and
text-align: left stays exactly where it was when you flip the document.
The logical equivalents mirror automatically:
| physical | logical |
|---|---|
margin-left |
margin-inline-start |
padding-right |
padding-inline-end |
left: 0 |
inset-inline-start: 0 |
text-align: left |
text-align: start |
border-left |
border-inline-start |
Adopt them everywhere and the majority of a layout mirrors with no RTL-specific CSS at all. That is the goal: not a second stylesheet, but one that was written in terms of start and end rather than left and right from the beginning.
The trap is partial adoption. A component that is 90% logical and 10% physical is worse than one that is entirely physical, because the physical remnant is now sitting in a mirrored context and lands somewhere nobody designed.
scrollLeft runs negative
This one is genuinely surprising, and it is not a bug — it is the spec.
In a left-to-right scroll container, scrollLeft starts at 0 and increases to
scrollWidth - clientWidth. In right-to-left, the resting position is still
0, but scrolling moves it down into negative numbers. On a container with
2297px of overflow, the reachable range is 0 to -2297.
Anything that assumes a positive range silently breaks:
// Wrong in RTL: the progress bar sits at 0 forever, because
// scrollLeft is never positive.
const progress = el.scrollLeft / (el.scrollWidth - el.clientWidth);
// Works in both: measure distance travelled, not signed position.
const max = el.scrollWidth - el.clientWidth;
const progress = max > 0 ? Math.abs(el.scrollLeft) / max : 0;
The same applies to drag handling. A pointer drag that computes
startScroll - deltaX will scroll the correct distance in the correct
direction in both modes — but the clamp is at the other end, so a test that
drags “left” in RTL is pushing against a wall and will look like the drag is
broken when it is fine. When you test this, drag both ways.
Transforms do not mirror either
transform: translateX(-50%) is a physical leftward shift. It stays leftward
under dir="rtl".
We hit this with a logo marquee. The track is laid out as a flex row and
animated by translating it to -50%, which loops seamlessly because the content
is duplicated. In LTR the track begins at the left edge and slides left, pulling
the second copy into view. Flip to RTL and the track begins at the right edge —
so sliding it left drags it straight out of the viewport, leaving a growing band
of empty space behind. The strip appeared to run once and then die.
The fix is to mirror the destination, not the mechanism:
@keyframes marquee-rtl {
from { transform: translate3d(0, 0, 0); }
to { transform: translate3d(50%, 0, 0); }
}
html[dir='rtl'] [data-marquee-track] {
animation-name: marquee-rtl;
}
Anything driven by a transform needs this treatment: carousels, parallax, slide-in panels, drawers. The browser will not do it for you.
dir on a block is not the same as dir on a span
You will have numbers, product codes, phone numbers and Latin brand names inside Arabic text. Those runs stay left-to-right, and the bidirectional algorithm mostly handles them — but not always, and the fix has a sharp edge.
Marking them explicitly is right:
<span dir="ltr">+963 99 20 444 50</span>
Putting that dir on the surrounding block is not. dir sets both the
bidi context and the alignment of the box, so a <dd dir="ltr"> inside an
Arabic page renders its content left-aligned while every sibling stays
right-aligned. We shipped exactly that: a statistics row where the values sat
hard against the left edge and their descriptions against the right. It looks
like a broken grid, and the cause is one attribute on the wrong element.
Keep dir on the smallest inline element that actually contains the
left-to-right run.
Some things must not flip
Mirroring is not universally correct, and a blanket transform: scaleX(-1) on
every icon is how you end up with a backwards logo.
Flip: arrows indicating forward or back, progress indicators, anything representing the reading direction, list markers and indentation.
Do not flip: logos and wordmarks, clock faces, media playback controls (play still points right in Arabic — it is a physical tape metaphor, not a reading one), checkmarks, most product photography, and any glyph containing Latin text.
The rule that holds: mirror things that mean forward; leave things that mean themselves.
Scroll snapping needs its padding mirrored too
If a horizontal scroller uses scroll-padding-left to align items against a
gutter, that padding stays on the physical left in RTL and the snap positions
land a gutter off. scroll-padding-inline-start mirrors correctly, and it needs
to match whatever inline padding the container carries — otherwise the first
item settles short of the position everything else on the page aligns to.
How to actually test it
Reading the Arabic is not the test, and neither is looking at a screenshot. The failures above are geometric, so measure geometry:
- Compare the leading edge of a component against the leading edge of the
heading above it. In LTR compare
getBoundingClientRect().left; in RTL compare.right. They should agree at every viewport width. - Check
document.documentElement.scrollWidth - clientWidthis0on both locales. Anything above zero is horizontal overflow, which in RTL usually means a physical property survived. - Drag or scroll every horizontal container in both directions and watch
scrollLeft. If one direction moves 1px and the other moves properly, you are hitting the clamp, not a bug.
None of this requires reading Arabic. It requires treating the mirrored layout as a layout in its own right, rather than a view of the real one.
The underlying point
RTL support is not a translation task that happens at the end. Almost every problem above comes from the same root: a decision made in physical terms — left, right, positive, forward — that only had one correct answer because only one direction was ever considered.
Write in logical terms from the first commit and most of this disappears. Retrofit it and you will find these one at a time, usually after someone else has.