We ship every Next.js template in English and Arabic, with a real right-to-left layout. After doing that across every website and dashboard we sell, the whole approach fits in one sentence:

Set dir on <html>, write spacing and position with logical utilities (ms-4, not ml-4), and keep the rtl: variant for the few things logical properties cannot express.

This post is the practical version of that sentence, with code from real projects. Every class below was checked against Tailwind CSS 4.3.

1. Put dir on <html>, on the server

Logical properties read the element's direction, the rtl: variant looks for it, and screen readers use lang to pick a voice. Put both on <html>, not on a wrapper: dialogs, menus and toasts usually render in a portal under <body> and only inherit a direction set above them.

With next-intl and the App Router, let app/[locale]/layout.tsx render <html>:

// src/config/i18n.ts export const directionOf = (locale: string): "rtl" | "ltr" => locale === "ar" ? "rtl" : "ltr"; 
// src/app/[locale]/layout.tsx (trimmed) export default async function LocaleLayout({ children, params }: { children: React.ReactNode; params: Promise<{ locale: string }>; }) { const { locale } = await params; if (!hasLocale(routing.locales, locale)) notFound(); setRequestLocale(locale); const messages = await getMessages(); return ( <html lang={locale} dir={directionOf(locale)} suppressHydrationWarning> <body> <NextIntlClientProvider messages={messages}>{children}</NextIntlClientProvider> </body> </html> ); } 

Do not set dir in a useEffect. The server HTML then arrives left to right, paints that way, and flips after hydration: a visible jump on every Arabic page load.

2. Logical utilities do most of the work

ml-4 is margin-left in both languages. ms-4 is margin-inline-start: the left in English, the right in Arabic. One class, both directions.

Physical Logical CSS
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-0 / right-0 inset-s-0 / inset-e-0 (or start-0 / end-0) inset-inline-start / -end
text-left text-start text-align: start
border-l border-s border-inline-start-width
rounded-l-lg rounded-s-lg the two start corners

Tailwind CSS 4.2 added inset-s-* and inset-e-* and deprecated start-* and end-*. The old names still compile to the same property, so existing code is fine.

What already flips without a new class: flex and grid rows, justify-start / justify-end, mx-*, px-*, and in Tailwind CSS 4 also space-x-* and divide-x. So never add flex-row-reverse "for Arabic": the row already reversed when the direction changed, and reversing it again puts it back in English order.

An input with an icon shows the pattern in one component:

<div className="relative"> <span className="pointer-events-none absolute start-4 top-1/2 -translate-y-1/2"> {startIcon} </span> <input className="h-12 ps-11" {...props} /> </div> 

Icon at start-4, text padded with ps-11. In Arabic both move to the right with no override.

Physical utilities are still right when the thing is physical: left-1/2 -translate-x-1/2 centres in both directions, and a before/after photo slider keeps "before" on the left in every language. Decide per element, and leave a comment so nobody "fixes" it later.

3. The rtl: variant, for what logical properties cannot express

Transforms, gradients, transform origins, keyframes and icon meaning have no logical version. That is what rtl: and ltr: are for. In Tailwind CSS 4.3 they compile like this:

.rtl\:-scale-x-100:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) { --tw-scale-x: calc(100% * -1); scale: var(--tw-scale-x) var(--tw-scale-y); } 

The :where() adds no specificity, and the selector matches the element's own direction, a dir="rtl" attribute, or any ancestor with dir="rtl". Keep that last part in mind (see the catch below).

Direction icons:

// Mirror a "next" arrow in Arabic <IconArrowRight aria-hidden className="h-5 w-5 rtl:-scale-x-100" /> 

Prefer -scale-x-100 (a true mirror) over rotate-180, which also turns the icon upside down. Flip arrows, chevrons and "view all" links. Do not flip play buttons, media progress bars, clocks or logos.

translate-x is physical, so anything that slides off screen needs a variant:

// A mobile menu that slides in from the inline start <div className={cn( "absolute top-0 start-0 h-full w-64 rounded-e-xl transition-transform", isOpen ? "translate-x-0" : "ltr:-translate-x-full rtl:translate-x-full", )} /> 

Gradients have no logical direction in Tailwind CSS 4.3:

<div className="absolute inset-0 bg-linear-to-r from-black/85 to-transparent rtl:bg-linear-to-l" /> 

Transform origin too: a progress bar should grow from the side the text starts on, ltr:origin-left rtl:origin-right.

Keyboard arrows are behaviour. In tabs or a slider, the right arrow key should move towards the next item on screen, which is to the left in Arabic:

const isRtl = useLocale() === "ar"; const forward = isRtl ? "ArrowLeft" : "ArrowRight"; 

The catch: rtl: still matches inside a dir="ltr" island

Because rtl: matches any dir="rtl" ancestor, it still applies inside a dir="ltr" element on an Arabic page. When you need the element's own direction, use an arbitrary variant on the :dir() pseudo-class:

<IconChevronRight className="[&:dir(rtl)]:-scale-x-100" /> 

4. What must never flip

Mixed runs of digits and punctuation take the direction of the Arabic around them, so phone numbers, prices and counters like 3 / 12 can come out scrambled. Isolate them.

Latin digits in Arabic: ask for them explicitly, because the region decides otherwise:

new Intl.NumberFormat("ar-SA").format(1248.6); // "١٬٢٤٨٫٦" new Intl.NumberFormat("ar-SA-u-nu-latn").format(1248.6); // "1,248.6" 

Prices:

<bdi dir="ltr" className="tabular-nums">{formatPrice(price, currency)}</bdi> 

Phone, email, URLs: typed and shown left to right, but aligned with the Arabic form:

<Input type="tel" dir="ltr" className="rtl:text-end" {...register("phone")} /> <a href={`mailto:${email}`} dir="ltr">{email}</a> 

Text written by users can be either language, so let the browser decide: <p dir="auto">{review.body}</p>.

Code blocks and Latin logos stay dir="ltr". Photos are never mirrored.

5. Arabic type: font, line height, letter spacing

Many Latin UI fonts have no Arabic glyphs, so load one font per script with next/font and switch under [dir="rtl"], Arabic first so Latin words inside Arabic text fall through to your Latin font:

[dir="rtl"] body { font-family: var(--font-arabic), var(--font-inter), system-ui, sans-serif; letter-spacing: 0; } 
  • Line height: a leading-[1.1] display heading can clip Arabic ascenders and diacritics. Open it up in Arabic only: leading-[1.1] rtl:leading-[1.4].
  • Letter spacing: Arabic is a connected script, and tracking makes it look broken. Reset it with rtl:tracking-normal.
  • Uppercase does nothing in Arabic, so an eyebrow label that relies on caps needs weight or colour instead.

6. A short RTL test pass

Run it on every page in Arabic, light and dark, phone and desktop:

  1. View source (not the inspector): <html lang="ar" dir="rtl"> is in the server HTML, and nothing flips after load.
  2. Sidebar, logo and main action sit on the right. Nothing is reversed twice.
  3. Drawers, menus and toasts open from the correct side, portals included.
  4. Arrows point the right way; play buttons and clocks do not flip.
  5. Phone, email and code fields type left to right.
  6. Prices, phone numbers and counters read in the right order.
  7. The Arabic font actually loads, headings do not clip, no Arabic letter spacing.
  8. Arrow keys in tabs and sliders follow the screen.

Then search for physical classes and decide on each hit:

grep -rnE --include='*.tsx' \ '(^|[^a-z0-9-])-?(ml|mr|pl|pr|left|right)-([0-9]|\[|\(|px|auto|full)|(^|[^a-z0-9-])(rounded|border)-(l|r|tl|tr|bl|br)([^a-z0-9]|-|$)|(^|[^a-z0-9-])(text|float)-(left|right)([^a-z0-9-]|$)' \ src 

On our apps it returns between 2 and 10 lines per app (centred elements, symmetric pairs, a physical slider, lines that already carry an rtl: override). Short enough to read every line, which is the point.

The full guide, with the next-intl routing and proxy setup, charts and time axes, unicode-bidi, self-hosted Arabic fonts and an FAQ, is on our blog: Tailwind CSS RTL Guide: Logical Properties and the rtl: Variant.

If you would rather start from code where this is already done: our Next.js templates ship in English and Arabic with full RTL. Websites and landing pages are $39, dashboards from $49, and full stack packages (website, dashboard and NestJS API) are $149, paid once. You can switch every live demo to Arabic at aniq-ui.com.