CSS Logical Properties: How I Replaced Left/Right Without Breaking Everything

I migrated a real client project from left/right CSS to logical properties without breaking layout or deadlines. This is what actually worked.
CSS Logical Properties: How I Replaced Left/Right Without Breaking Everything
Photo by KOBU Agency / Unsplash

Why I stopped trusting left and right

I inherited a multilingual project that looked fine in Dutch and English.

Then the client asked for Arabic.

Everything broke in exactly the way you expect. Icons on the wrong side. Buttons flipped. Grid gaps weird. At that point I realised something very simple.

I was still writing CSS like the web is always left-to-right.

I had read about logical properties before. margin-inline, padding-block, inset-inline-start. All that. I thought it was nice theory for conference talks.

Then I had to ship RTL in a real product without rewriting every component. That is when logical properties stopped being academic and started being survival.

The starting point: 6,000 lines of physical CSS

This was a medium size design system for a SaaS product. One repo, multiple themes, a mess of BEM-ish CSS and component-scoped styles.

  • Lots of margin-left nudges for spacing.
  • padding-right for icons in inputs.
  • left and right for absolutely positioned badges.
  • Text alignment hard-coded with text-align: left.

There was no way I could freeze the project for a week and “just refactor CSS”. This needed to ship in small, safe chunks.

So I treated it like any other migration. Think TypeScript in a big JS codebase, or React hooks in a class-based app. You do it slice by slice, and you keep the lights on while you move.

The mindset shift: content vs physical

The first thing I had to fix was how I think about directions.

Physical directions are top, right, bottom, left. They are tied to the screen edges.

Logical directions are relative to the content flow.

  • Inline axis: where text flows. inline-start and inline-end.
  • Block axis: where blocks stack. block-start and block-end.

For a typical Western language:

  • Inline axis: left to right.
  • Block axis: top to bottom.

For Arabic, the inline axis flips. Layout written with logical properties sees that change and behaves. Layout written with left and right does not.

So I set myself a simple rule.

  • If this thing is about content flow, I use logical properties.
  • If this thing is about the actual viewport edge, I keep physical properties.

That distinction made the migration much less scary.

The safe starting point: margins and paddings

Touching positioning first felt risky. Margins and paddings were safe.

I started there.

I wrote a tiny script to scan for margin-left and margin-right in the repo. It just printed counts per file. No autofix. I was not going to trust a regex with this.

The first pattern was obvious:

.btn-icon {
  padding-left: 0.5rem;
}

.card {
  margin-right: 1.5rem;
}

These were exactly the cases I wanted logical properties for.

I started replacing them one by one:

.btn-icon {
  padding-inline-start: 0.5rem;
}

.card {
  margin-inline-end: 1.5rem;
}

Zero visual change in LTR. Exact same result in the browser. But when we eventually switched the document to dir="rtl", the spacing flipped automatically.

A nice bonus. Logical properties map to shorthands.

  • margin-inline is margin-inline-start and margin-inline-end.
  • padding-block is padding-block-start and padding-block-end.

So patterns like this:

.stack > * + * {
  margin-top: 1rem;
}

became:

.stack > * + * {
  margin-block-start: 1rem;
}

And two-sided spacing like this:

.tag {
  padding-left: 0.5rem;
  padding-right: 0.5rem;
}

became this:

.tag {
  padding-inline: 0.5rem;
}

That alone removed dozens of lines and made the intent clearer. It also made it obvious which components had weird asymmetrical spacing that probably came from pixel-pushing during QA.

Text alignment and the fake problem of “left”

The next big cluster was text alignment.

The codebase was full of this:

.table-cell {
  text-align: left;
}

.total {
  text-align: right;
}

For most body text there was no good reason to force left. I removed a lot of those rules entirely and just let the browser decide based on dir.

For intentional alignment, I switched to logical keywords.

.table-cell {
  text-align: start;
}

.total {
  text-align: end;
}

That was the first moment where RTL actually started to look “native” without extra stylesheets.

One caveat. If your design really wants numbers to be on the right side in both LTR and RTL, you probably do want right there. Not end. I hit that once in a finance summary.

Positioning: where the chaos lives

Margins were easy. Text alignment was easy. Positioning was not.

I am talking about everything that uses position: absolute or relative coordinates.

Think badges in the corner of a card.

.card-badge {
  position: absolute;
  top: 0.5rem;
  right: 0.5rem;
}

Or inputs with icons.

.input-icon {
  position: absolute;
  left: 0.75rem;
  top: 50%;
  transform: translateY(-50%);
}

This is where physical directions are very tempting, because it is literal pixels from the viewport side. That is why I forced myself to ask one question for every positioned element:

Is this thing visually bound to the content direction, or to the viewport edge?

If the answer was “content direction”, I used logical properties.

.card-badge {
  position: absolute;
  inset-block-start: 0.5rem;
  inset-inline-end: 0.5rem;
}

Icons next to text? Same idea.

.input-icon {
  position: absolute;
  inset-inline-start: 0.75rem;
  inset-block-start: 50%;
  transform: translateY(-50%);
}

When we flipped to RTL, badges moved to the other visual side automatically. That matched the design spec. No separate RTL stylesheet. No conditional classes.

For elements that are tied to the viewport edge, I kept physical properties on purpose.

.global-toast {
  position: fixed;
  top: 1rem;
  right: 1rem;
}

This toast lives in the corner of the screen, not “at the end of the content flow”. Keeping this physical stopped me from being clever where I did not need to be.

Grids and flex: explicit inline and block axes

The layout system used a mix of CSS Grid and Flexbox. This is where logical properties feel very natural for me, because both layout models already think in axes.

For flex layout we had things like this:

.toolbar {
  display: flex;
  flex-direction: row;
  justify-content: flex-end;
  gap: 0.5rem;
}

I stopped using row and column mentally and just described them as:

  • Inline axis flex.
  • Block axis flex.

So I refactored like this:

.toolbar {
  display: flex;
  flex-direction: row; /* kept as-is */
  justify-content: flex-end;
  gap: 0.5rem;
}

But I changed spacing and alignment rules to use logical keywords where possible. For example, margin-left: auto to push something to the right became this:

.toolbar-spacer {
  margin-inline-start: auto;
}

On Grid, switching to logical gaps was straightforward:

.layout {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 320px;
  column-gap: 2rem;
  row-gap: 1.5rem;
}

became:

.layout {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 320px;
  column-gap: 2rem; /* kept for clarity */
  row-gap: 1.5rem;
}

I actually kept column-gap and row-gap here. Not because logical props would not work. Mostly because the main issue was inline spacing between content, and we were not flipping columns for RTL in this design.

If I ever do, I will probably switch to:

.layout {
  display: grid;
  grid-auto-flow: column;
  gap: 1.5rem 2rem; /* block inline */
}

and then let the axes handle it.

When I did not use logical properties

Logical properties are powerful, but using them everywhere is the new version of using !important everywhere. It works, but it is lazy thinking.

I kept physical properties in these places:

  • Viewport-tied UI like drawers that always slide in from the physical right edge.
  • Background gradients that are visually anchored to the left edge of the screen.
  • Animations that literally move from off-screen left to center.

For example, a slide in panel:

.side-panel {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  width: 320px;
  transform: translateX(100%);
}

The design decision here was: this panel always comes from the same physical side, regardless of content direction. That is a product choice, not a technical limitation.

I think this is where people get burned. They use logical properties where the UX does not actually want to flip. Then RTL feels weird, just in a different way.

The practical migration workflow

Here is the rough order I used on the client project. It let me ship incremental improvements without freezing feature work.

1. Add dir toggling early

We added a tiny dev-only toggle in the app shell:

<html dir="ltr">

became runtime configurable in development. A simple button that switched dir between ltr and rtl.

That toggle lived in a corner of the dev toolbar. Every time I touched CSS, I would click it. If something exploded, I knew exactly which change caused it.

2. Switch spacing to inline/block in leaf components

I started from the leaf components. Buttons, tags, badges, inputs. The stuff that does not control layout, just its own box.

All their margin-left/right and padding-left/right conversions were basically free wins. Very low risk.

3. Refactor text alignment globally

Then I did a pass for text-align. I removed rules that were redundant, and switched the rest to start and end when they were clearly about flowing content.

I ran visual regression tests on both LTR and RTL snapshots after that. No red flags. Mostly things got simpler.

4. Tackle positioning case by case

Positioning was the only part I did by hand, with design side by side. For every left or right, I checked with a designer if the intent was “content side” or “viewport side”.

I used inset-inline-start / end and inset-block-start where it followed content. I left it alone where it did not.

This pass is where you catch the weird hacks too. Things like top: 1px to compensate for a missing line-height. Logical properties do not fix that, but refactoring makes it visible.

5. Enforce new patterns in code review

The last step was cultural. We added a simple rule to the frontend checklist.

  • No new margin-left/right or padding-left/right.
  • Prefer start/end over left/right for text alignment.
  • Think before using physical positioning in components.

I even added a basic stylelint rule to flag new physical margins and paddings. Not to block the build, just to guilt-trip the author during development.

What actually improved

This was not a theoretical exercise. It changed how the project behaved.

  • RTL support became boring. We did not need a separate RTL stylesheet. Just a few targeted overrides. Most of the layout obeyed dir.
  • Design intent was clearer. margin-inline-start explains more than margin-left when you read it fresh six months later.
  • Less code. A lot of duplicated left/right pairs collapsed into logical shorthands.

There were also a few surprises.

We caught a bunch of historical hacks. Spacing that existed only to compensate for icons that had moved three designs ago. When you rewrite to logical axes, those hacks stick out. They no longer make sense in the new mental model.

We also saw designers become more careful with “always right side” requests. Once they saw RTL in the same environment, some of those specs changed to “content end” instead.

If you start from scratch

If I start a new project tomorrow, I do not touch margin-left or padding-right at all. I go straight for logical properties.

  • margin-inline and padding-inline for horizontal spacing.
  • margin-block and padding-block for vertical spacing.
  • inset-inline-start/end for content-bound positioned elements.
  • text-align: start/end for alignment that should track content direction.

Then I use physical properties only when I explicitly want to anchor to the viewport.

That split keeps my mental model simple. Content vs screen. Inline vs block. Start vs end.

It also means if a client drops “we need RTL” on the roadmap late, I do not reach for a second stylesheet. I just flip dir and start fixing the handful of edge cases instead of hundreds.

Logical properties will not magically make your CSS good. They will force you to think more clearly about what your layout is tied to. That alone is worth the migration.

Subscribe to my newsletter

Subscribe to my newsletter to get the latest updates and news

Member discussion