Skip to content
← All decisions

One component tree, two directions

accepted

Context

This site renders in Persian (RTL) and English (LTR) from one component tree. The obvious risk is not that mirroring is hard — Tailwind v4 ships logical utilities and they cover most of it. The risk is that getting it wrong is silent.

A physical utility like ml-4 or origin-right compiles, passes lint, passes type-check, and looks perfectly correct in whichever direction the author happened to be looking at. It is wrong only in the other one, and only to someone who reads that direction.

I know this because I have already shipped it. In a previous project a progress bar reads:

<div className="h-full origin-right bg-linear-to-l from-accent-500 to-accent-300" />

with transform: scaleX(pct). Both origin-right and bg-linear-to-l are hardcoded for RTL. In LTR the bar grows from the wrong edge and the gradient points backwards. Nothing errors. In the same codebase, a @keyframes sweep animation translates a physical -120% → 320% and runs backwards in LTR for the same reason.

Options considered

Duplicate the components per direction. Honest, and it works. It also doubles the surface area of every future change and guarantees the two copies drift.

A mirroring library. Rewrites physical properties to logical at build time. It adds a dependency that owns my CSS pipeline, and it cannot fix the cases below that have no logical equivalent at all.

Logical properties everywhere, plus a direction token for the gaps. More discipline required, no dependency, and — importantly — the discipline can be enforced by a script instead of by memory.

Decision

Logical utilities by default. For the properties that genuinely have no logical form, a single direction sign declared once:

:root { --dir: 1; --shadow-x: 18px; --grad-angle: 90deg; }
[dir="rtl"] { --dir: -1; --shadow-x: -18px; --grad-angle: 270deg; }

Every physical value is then expressed through it — translateX(calc(var(--dir) * 12px)), linear-gradient(calc(145deg * var(--dir)), …). Because --dir is a custom property, JavaScript can read the same value with getComputedStyle, so carousel and scroll arithmetic can never drift from the stylesheet.

That last part is not a new idea for me. In an earlier project I read the colour palette out of CSS custom properties so a <canvas> could never disagree with the stylesheet. This is the same technique applied to direction instead of colour.

Then the part that actually matters: scripts/check-direction.mjs fails the build on any physical utility that has a logical twin, unless it is explicitly prefixed rtl: or ltr: to say this is deliberate. Running it against the progress-bar code above produces four errors.

Tradeoffs accepted

  • The gate reads source text, so a class name assembled at runtime is invisible to it. I keep class strings literal rather than weaken the check to cover a case I do not need.
  • The escape hatch can be abused. rtl: silences the gate. That is intentional — the goal is to make the physical choice visible in review, not impossible.
  • A false positive costs a comment. So far there have been none, but the honest fix if one appears is a parser, not a wider exception.

Result

Zero physical utilities in the codebase. The site renders both directions from one tree, and the class of bug that shipped in my earlier project cannot reach main here — not because I am more careful, but because the build refuses it.

What I would do differently

I would write the gate before the components rather than after. I wrote roughly a dozen components first, then the gate, and then had to go back and fix what it found. The gate is thirty minutes of work and it changes how you write the components — knowing the check exists means you reach for ms- first instead of reaching for ml- and cleaning up later.

I would also extend it to catch scrollLeft, which is the one remaining direction landmine it does not cover. Chrome, Firefox and Safari historically disagreed on its sign in RTL, so any arithmetic written against it is wrong in at least one engine.