Tailwind v4 Logical Properties: Ship RTL Layouts Without a Single rtl: Override

Tailwind logical properties ship RTL layouts with zero rtl: overrides. The full ms/me/ps/pe/start/end set, the corner radius trap, and a Pest browser test.

Steven Richardson
Steven Richardson
· 9 min read

The first Arabic locale request is what exposes how much direction your markup encodes that it never needed to. I got about forty classes into turning ml-3 on a client dashboard into rtl:ml-0 rtl:mr-3 before admitting the whole exercise was pointless: the logical utilities have been in Tailwind the entire time, and the browser does the flipping for free.

Here is the conversion in the order that doesn't leave the layout broken halfway through. Every class below was compiled against tailwindcss@4.3.3 while writing this, and a couple of things the internet still repeats about RTL in Tailwind turned out to be wrong.

Set the dir attribute from the application locale#

Nothing else in this article works until the document declares a writing direction, because logical properties resolve against dir (or a CSS writing-mode). Set it once on the <html> element from Laravel's active locale and every logical utility on the page starts behaving.

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}"
      dir="{{ in_array(app()->getLocale(), ['ar', 'he', 'fa', 'ur']) ? 'rtl' : 'ltr' }}">

That inline ternary gets old fast. I push it into a tiny helper so the list of RTL locales lives in one place:

<?php

namespace App\Support;

class Direction
{
    /** @var list<string> Locales written right-to-left. */
    private const RTL_LOCALES = ['ar', 'he', 'fa', 'ur', 'ps', 'sd', 'yi'];

    public static function forLocale(?string $locale = null): string
    {
        $locale = $locale ?? app()->getLocale();

        // Match on the primary subtag so ar-SA and ar_EG both resolve.
        return in_array(strtok(str_replace('_', '-', $locale), '-'), self::RTL_LOCALES, true)
            ? 'rtl'
            : 'ltr';
    }
}

dir is not limited to the root element. A single Arabic review inside an otherwise English page should carry its own dir="rtl" on the wrapper — the inline axis flips for that subtree only, which is exactly the behaviour you want and exactly what a build-time class flip cannot give you.

Swap directional margin and padding for the logical equivalents#

Replace ml-*/mr-* with ms-*/me-* and pl-*/pr-* with ps-*/pe-*. These compile to inline-axis properties, so the same class produces a left margin in English and a right margin in Arabic with no second class involved.

/* Emitted by tailwindcss@4.3.3 */
.ms-4  { margin-inline-start: calc(var(--spacing) * 4); }
.me-4  { margin-inline-end:   calc(var(--spacing) * 4); }
.ps-4  { padding-inline-start: calc(var(--spacing) * 4); }
.pe-4  { padding-inline-end:   calc(var(--spacing) * 4); }

Two details that save time. Negative values work — -ms-4 and -me-4 both compile — and ms-auto/me-auto exist, so the mr-auto "push everything else left" idiom becomes me-auto.

The bigger surprise is that you probably have less work than you think. Since v4, the x/y shorthands are already logical: mx-4 emits margin-inline, px-4 emits padding-inline, inset-x-4 emits inset-inline, and scroll-px-4 emits scroll-padding-inline. If your codebase leans on the shorthands, most of it is direction-safe already. There is also a block-axis pair most people never meet — mbs-*/mbe-* for margin-block-start/margin-block-end, and pbs-*/pbe-* for padding — which matter only if you support vertical writing modes.

Replace left and right positioning with start and end#

Absolute and fixed positioning is where RTL breaks most visibly: a close button pinned with right-4 stays welded to the right edge no matter what the document direction says. Swap to start-* and end-*, which emit inset-inline-start and inset-inline-end.

<!-- Before: the close button never moves -->
<div class="relative">
  <button class="absolute top-4 right-4">×</button>
</div>

<!-- After: the button follows the reading direction -->
<div class="relative">
  <button class="absolute top-4 end-4">×</button>
</div>

start-auto and end-auto compile, negatives like -start-4 compile, and inset-s-0/inset-e-0 exist as the single-side inset shorthand. Note that top-* and bottom-* stay as they are: they are block-axis, and the block axis does not flip for RTL.

Convert borders, text alignment and floats#

The remaining directional utilities all have a logical twin, and the border ones cover colour as well as width — a detail that trips people up, because they convert border-l and leave border-l-red-500 behind.

Physical Logical Compiles to
ml-4 / mr-4 ms-4 / me-4 margin-inline-start / -end
pl-4 / pr-4 ps-4 / pe-4 padding-inline-start / -end
left-4 / right-4 start-4 / end-4 inset-inline-start / -end
border-l / border-r border-s / border-e border-inline-start-width / -end-width
border-l-red-500 border-s-red-500 border-inline-start-color
text-left / text-right text-start / text-end text-align: start / end
float-left / float-right float-start / float-end float: inline-start / inline-end
clear-left / clear-right clear-start / clear-end clear: inline-start / inline-end
scroll-ml-4 / scroll-pl-4 scroll-ms-4 / scroll-ps-4 scroll-margin-inline-start / scroll-padding-inline-start

text-start is the one worth doing first. It is a single-token change, it applies to almost every text block in the app, and misaligned body copy is the most obvious RTL failure a reviewer will spot.

While you are in the borders, border-s-2 and friends work as expected. There is also border-bs/border-be for the block axis if you ever need it.

Fix the four logical corner radius utilities#

Corner radii are where the conversion quietly goes wrong, because the logical names look like size modifiers rather than positions. rounded-ss-lg is start-start, not "small". Read it as two axes: the first letter is the block axis, the second is the inline axis.

<div class="rounded-ss-lg rounded-se-lg rounded-es-lg rounded-ee-lg">
  <!--
    ss = start-start  → top-left in LTR,     top-right in RTL
    se = start-end    → top-right in LTR,    top-left in RTL
    es = end-start    → bottom-left in LTR,  bottom-right in RTL
    ee = end-end      → bottom-right in LTR, bottom-left in RTL
  -->
</div>
.rounded-ss-lg { border-start-start-radius: var(--radius-lg); }
.rounded-se-lg { border-start-end-radius:   var(--radius-lg); }
.rounded-es-lg { border-end-start-radius:   var(--radius-lg); }
.rounded-ee-lg { border-end-end-radius:     var(--radius-lg); }

There are also two-corner shorthands: rounded-s-lg sets both start-side corners, rounded-e-lg both end-side corners. The t/r/b/l family — rounded-tl-lg, rounded-br-lg and the rest — remains physical and will not flip, so a segmented control built with rounded-l-md/rounded-r-md needs rounded-s-md/rounded-e-md instead.

Stop worrying about space-x and divide-x#

Skip this refactor. Almost every RTL-in-Tailwind article warns that space-x-* and divide-x-* are physical and will survive an audit only to break your layout — that was true in v3 and it is not true now. In v4 both emit inline-axis properties:

:where(.space-x-4 > :not(:last-child)) {
  --tw-space-x-reverse: 0;
  margin-inline-start: calc(calc(var(--spacing) * 4) * var(--tw-space-x-reverse));
  margin-inline-end:   calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse)));
}

:where(.divide-x-4 > :not(:last-child)) {
  --tw-divide-x-reverse: 0;
  border-inline-style: var(--tw-border-style);
  border-inline-start-width: calc(4px * var(--tw-divide-x-reverse));
  border-inline-end-width:   calc(4px * calc(1 - var(--tw-divide-x-reverse)));
}

space-y-* moved to margin-block-start/margin-block-end at the same time. I still reach for gap-* on a flex or grid parent in new work, because the space utilities are a margin hack that falls apart on wrapped rows and custom orders, and because gap composes properly with safe alignment in flex and grid. But converting existing space-x-* purely for RTL buys you nothing — compile a probe and confirm against your own version before you spend the afternoon.

Keep rtl: only for things that must physically mirror#

Some things genuinely need to flip as pixels, not as layout: a "next" chevron, a directional illustration, a box-shadow offset that suggests a light source. That is what rtl: and ltr: are for, and it is the entire remaining scope of those variants.

<button class="inline-flex items-center gap-2">
  Next
  <svg class="size-4 rtl:-scale-x-100"><!-- chevron pointing right --></svg>
</button>

The variant is not a build-time flip. It compiles to a :where() selector combining the :dir() pseudo-class with the dir attribute, so it responds to a per-element dir exactly like the logical utilities do:

.rtl\:ms-4:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) {
  margin-inline-start: calc(var(--spacing) * 4);
}

If you find yourself writing rtl: for spacing, alignment or positioning, you have missed a logical utility. Reach for the same attribute-driven thinking you would use with data and ARIA attribute variants — the state lives in the DOM, and the selector reads it.

Audit the codebase for the utilities that are still physical#

Ripgrep the Blade and JSX for the physical utilities that survived, and fix them in review rather than trusting a sed pass. Class names appear inside strings, comments and prose, so this list needs human eyes.

rg -n --glob '*.{blade.php,jsx,tsx,vue}' \
  '\b-?(ml|mr|pl|pr|left|right|border-l|border-r|text-left|text-right|float-left|float-right|rounded-t[lr]|rounded-b[lr])-'

Expect false positives: the word "right" in body copy, and text-left inside a <pre> block where physical alignment is genuinely what you want for code. Also expect misses — anything assembled at runtime from a variable won't be in the source at all, which is the same reason dynamic Blade classes need safelisting.

Two things the audit will not fix. Third-party component libraries that hardcode ml-2 internally are beyond the reach of your class order; you either patch them or pick a different component. And there is no mis-*, mie-* or inset-inline-* utility — I have watched people reach for the raw CSS property name, fail to find a class, and fall back to arbitrary properties like [margin-inline-start:7px]. Arbitrary values are fine (ms-[7px] compiles), and if you truly need something the utilities don't cover, define a real utility with @utility rather than scattering bracket syntax.

Verify both directions in the browser and in a Pest browser test#

Toggle dir on <html> in devtools, watch the layout mirror, then check the computed styles panel — you want to see margin-inline-start resolving to a right margin under RTL. Then lock it in so the next component doesn't quietly regress. Pest's browser plugin can set the locale per visit and assert the computed value:

<?php

it('mirrors the card layout in RTL', function () {
    $ltr = visit('/dashboard')->withLocale('en-GB');

    $ltr->assertAttribute('html', 'dir', 'ltr')
        ->assertScript(
            "getComputedStyle(document.querySelector('[data-test=card-body]')).marginLeft",
            '16px',
        );

    $rtl = visit('/dashboard')->withLocale('ar-SA');

    // Same class, same computed value — on the opposite physical side.
    $rtl->assertAttribute('html', 'dir', 'rtl')
        ->assertScript(
            "getComputedStyle(document.querySelector('[data-test=card-body]')).marginRight",
            '16px',
        );
});

Asserting the physical computed property is the point: it proves the browser resolved the logical property differently in each direction. If screenshots suit you better, assertScreenshotMatches() gives you a diff per direction — the setup is covered in Pest 4 browser testing with Playwright.

One honest caveat before you call the ticket done. Mirroring the layout is maybe a third of RTL support. Numeral systems, date and currency formatting, icons whose meaning is directional rather than their shape, and line-breaking all sit outside CSS entirely. Logical properties stop you shipping a visually broken page; they do not make the product feel native to an Arabic reader. If you are still on Tailwind v3 and none of these utilities resolve, the v3 to v4 upgrade tool is the prerequisite for all of it.

FAQ#

What is the difference between ml-4 and ms-4 in Tailwind?

ml-4 compiles to margin-left, a physical property that always puts space on the left edge regardless of writing direction. ms-4 compiles to margin-inline-start, which the browser resolves to the left in a left-to-right document and the right in a right-to-left one. The spacing value is identical; only the side it lands on differs.

How do I support RTL languages in Tailwind CSS?

Set dir="rtl" on the <html> element (or on any subtree that needs it) based on the active locale, then use the logical utilities — ms/me, ps/pe, start/end, border-s/border-e, text-start/text-end — instead of their left/right equivalents. The browser handles the mirroring, so you write each spacing decision once. Only genuinely mirrored graphics need a rtl: variant.

Do I still need the rtl: variant if I use logical properties?

For layout, no. Spacing, padding, positioning, borders, text alignment and floats all have logical utilities that flip on their own. You still need rtl: for things that must physically mirror rather than re-flow — a directional chevron, a background image, a box-shadow offset — which in practice is a handful of classes across an entire application.

What does rounded-ss-lg mean in Tailwind?

ss is start-start, not "small". The first letter is the block axis and the second is the inline axis, so rounded-ss-lg sets border-start-start-radius: the top-left corner in LTR and the top-right corner in RTL. The full set is ss, se, es and ee, plus the rounded-s-* and rounded-e-* two-corner shorthands.

Does space-x work with RTL layouts in Tailwind?

In Tailwind v4, yes. space-x-* emits margin-inline-start and margin-inline-end, and divide-x-* emits border-inline-start-width and border-inline-end-width, so both follow the writing direction. This changed from v3, where they were physical, which is why so much RTL advice still lists them as a trap. Verify against your installed version before refactoring.

How do I convert an existing Tailwind project to logical properties?

Set dir from the locale first, then convert in this order: margin and padding, positioning, borders and text alignment, and finally corner radii. Ripgrep for the remaining physical utilities and review the matches by hand, because class names show up in prose and in code samples where physical alignment is correct. Finish with a browser test that loads a representative page in both directions and asserts a computed style.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.