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.