Building Arabic RTL interfaces in Next.js and TypeScript
Practical rules for Arabic right-to-left UIs in Next.js: logical CSS, dir handling, bidi text, icon mirroring, numerals and testing.
5 min readnextjs · typescript · rtl · i18n · css
Setting direction: rtl on the body takes five minutes. Shipping an Arabic interface that a native reader never has to think about takes a lot longer, and almost none of that time goes into the direction property.
Arabic is my first language, and I've built RTL screens since my early agency work. The project that sharpened my approach was at Logikey, where I rebuilt the front end of a delivery and logistics app used by truck drivers. The old app was JavaScript React; the new one was Next.js with TypeScript and full Arabic RTL support. These are the rules I'd hand to anyone starting the same job.
Direction is document state
Put lang and dir on the html element and let everything inherit. Don't sprinkle dir across individual components, and don't let components decide "is this Arabic?" on their own. Derive direction from the locale in exactly one function:
// lib/direction.ts
const RTL_LANGUAGES = new Set(['ar', 'fa', 'he', 'ur']);
export function getDirection(locale: string): 'rtl' | 'ltr' {
return RTL_LANGUAGES.has(new Intl.Locale(locale).language) ? 'rtl' : 'ltr';
}
// Root layout (or _document with the Pages Router):
// <html lang={locale} dir={getDirection(locale)}>Components that need the value at runtime, like carousels, swipe gestures or popover placement, should read it from one context provider fed by the same function. Most component libraries ship a direction provider; wire it to this value instead of creating a second source of truth.
Think in start and end
The biggest single change is replacing physical CSS properties with logical ones. margin-left becomes margin-inline-start, padding-right becomes padding-inline-end, left: 0 becomes inset-inline-start: 0, and text-align: left becomes text-align: start. Write them once and both directions work.
Flexbox and grid already follow dir. A flex-direction: row toolbar flows right to left in Arabic without any help. The classic mistake is adding row-reverse "for RTL" and flipping the layout twice.
A few things are not logical and need explicit handling: transforms, gradients written as to right, background-position and shadow offsets. A slide-in drawer animated with translateX(100%) will enter from the wrong side.
.card {
padding-inline: 1rem 1.5rem;
border-inline-start: 3px solid var(--accent);
text-align: start;
}
.drawer {
inset-inline-end: 0;
transform: translateX(100%);
}
[dir='rtl'] .drawer {
transform: translateX(-100%);
}
/* Only icons that express direction get flipped. */
[dir='rtl'] .icon[data-mirror='true'] {
transform: scaleX(-1);
}Once the codebase is converted, add a stylelint rule that flags left, right, margin-left and friends, so physical properties don't creep back in through new code.
Mirror icons that mean direction, and nothing else
Not every icon flips. My rule of thumb:
- Mirror: back and forward arrows, chevrons on "next" buttons and breadcrumbs, and icons that show text flow, such as indent or a bulleted list.
- Don't mirror: checkmarks, clocks (clockwise is clockwise everywhere), play and pause buttons, logos, and anything that depicts a real object with a fixed orientation.
- Maps never mirror. North stays up. In a logistics app with routing and scheduling screens, the map controls can move to the other side, but the map itself is geography, not text.
Make mirroring an explicit property of each icon in the design system, off by default, like the data-mirror attribute above. A global "flip every icon" rule produces backwards checkmarks.
Bidi text is where the real bugs live
Arabic screens are full of left-to-right fragments: order references, vehicle plates, emails, English product names, codes. The Unicode bidi algorithm handles most of it, but neutral characters at the edge of a fragment, like punctuation, plus signs, slashes and brackets, take their direction from the surrounding paragraph. C++ inside an Arabic sentence can render as ++C, and a value that ends in a bracket can end up attached to the wrong side.
The fix is isolation:
- Wrap interpolated values in a
bdielement. - Put
dir="auto"on elements that display user-generated text. - For plain strings, such as toasts,
titleattributes or push notifications, wrap the value in the Unicode isolate characters FSI and PDI (there's a helper below).
Inputs need the same care. A free-text field gets dir="auto" so it follows whatever the user types. Fields that are always left-to-right, such as emails, URLs, phone numbers and codes, get dir="ltr" and an explicit alignment, so they don't jump to the far edge of an otherwise right-aligned form.
Choose your numerals on purpose
Arabic uses two digit systems in everyday life: Western digits (0 to 9) and Arabic-Indic digits. Which one users expect depends on the country and the context, and the default for a bare ar locale isn't something to rely on. Next.js adds a trap: if the server's ICU data and the browser's disagree, the formatted string differs between the server render and hydration.
So I pass the numbering system explicitly with the nu extension, and the calendar with ca, since some Arabic locales default to a non-Gregorian calendar. On input, accept both digit systems and normalise before validating:
export const formatNumber = new Intl.NumberFormat('ar-u-nu-latn').format;
export const formatDate = new Intl.DateTimeFormat('ar-u-ca-gregory-nu-latn', {
dateStyle: 'medium',
}).format;
// Users type whichever digits their keyboard gives them.
export function normalizeDigits(input: string): string {
return input.replace(/[٠-٩۰-۹]/g, (d) =>
String(d.charCodeAt(0) & 0xf),
);
}
// Isolate a value inside a plain string: FSI + value + PDI.
export const isolate = (value: string) => `${value}`;Test in RTL from day one
RTL is not a QA phase at the end. The habits that pay off:
- A development toggle that flips
dirwhile keeping the strings in English. Physical CSS shows up immediately, and you don't need to read Arabic to spot it. - Unit tests for the direction helper, the formatters and digit normalisation. They're cheap, and they pin down the decisions above.
- Component tests that render with
dir="rtl"and check the things that tend to break: which icons carry the mirror flag, which inputs are forced toltr. - Screenshot tests of key screens in both directions. An RTL screenshot next to its LTR twin catches spacing and alignment bugs that no assertion will.
- A native reader looking at realistic data. Placeholder Arabic hides every bidi problem, because the bugs only appear when Latin fragments and digits are mixed in.
If you take one thing from this, make it these three decisions: set direction once at the root, write every style in terms of start and end, and treat mixed-direction text as data that needs isolating. They remove most RTL bugs before they're written, and they shrink the rest (icons, numerals, transforms) to a size you can catch in code review.