Skip to content

4 August 2026 · 8 min read

RTL is not a mirror

Flipping a layout right-to-left is the easy part. These are the things that do not flip on their own, and the ones that must not flip at all.

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 - clientWidth is 0 on 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.

Get in touch

Tell us what is stuck

Thirty minutes, no slides needed. Tell us the problem and we will come back with our first read on it.

What happens next

  1. 1. You pick a time, or just send us the problem.
  2. 2. We reply within one working day.
  3. 3. You get our first read on it. No charge, no deck.