Right-to-Left Layouts on Static Sites

Adding Arabic, Hebrew, Persian or Urdu to a static site is the i18n change that touches CSS, not just content. The whole page mirrors: the sidebar moves to the right, the "next page" arrow points left, text aligns to the right edge, and list bullets hang on the right. Code, URLs and numbers inside that text still run left to right. A site written with physical CSS properties needs a second stylesheet full of overrides; a site written with logical properties needs almost nothing.

This guide adds Arabic to a four-language documentation site built with Astro and Hugo prototypes: setting direction per locale, converting CSS to logical properties, handling icons and code, choosing fonts that keep the Largest Contentful Paint budget, and testing it all in CI. It belongs to Internationalization for Static Sites.

Prerequisites

Step 1: Set lang and dir From the Locale

Direction belongs on the root element so everything inherits it:

---
const locale = Astro.currentLocale ?? 'en';
const dir = ['ar', 'he', 'fa', 'ur'].includes(locale) ? 'rtl' : 'ltr';
---
<html lang={locale} dir={dir}>

In Hugo, set languageDirection = "rtl" in the Arabic language block and use <html lang="{{ site.Language.LanguageCode }}" dir="{{ or site.Language.LanguageDirection "ltr" }}">. Do not use CSS direction: rtl instead; the attribute also drives the bidirectional text algorithm, form controls, scrollbars and screen readers.

Step 2: Convert Physical CSS to Logical Properties

Logical properties describe layout relative to the writing direction. margin-inline-start is the left margin in English and the right margin in Arabic. The conversion is mechanical:

PhysicalLogical
margin-left / margin-rightmargin-inline-start / margin-inline-end
padding-left: 1rem; padding-right: 1rempadding-inline: 1rem
left: 0inset-inline-start: 0
text-align: lefttext-align: start
border-leftborder-inline-start
float: leftfloat: inline-start
border-top-left-radiusborder-start-start-radius

Flexbox and grid already follow the direction: flex-direction: row lays items out right to left in an RTL document, and justify-content: flex-start means the right edge. Most layouts built on flex and grid need only the margins, padding and positions converted.

Stylelint can enforce this so physical properties do not creep back in:

{
  "plugins": ["stylelint-use-logical"],
  "rules": { "csstools/use-logical": ["always", { "except": ["top", "bottom", "margin-top", "margin-bottom"] }] }
}
Elements that mirror in RTL compared with those that stay LTR Two panels. Mirrors with dir rtl includes page layout and sidebar position, text alignment and list indentation, navigation arrows and chevrons, breadcrumbs order and progress indicators. Stays left to right includes code blocks and inline code, URLs, file paths and commands, numbers and version strings, logos, media controls and checkmarks. What mirrors and what does not Mirrors with dir=rtl page layout and sidebar side text alignment and list indents back and next arrows breadcrumb order progress and step indicators Stays left-to-right code blocks and inline code URLs, file paths and commands numbers and version strings logos, media controls, checkmarks
The rule of thumb is that anything describing reading order mirrors; anything with its own intrinsic direction does not.

Step 3: Icons and Code

Directional icons need flipping. A single rule handles SVG icons marked as directional:

[dir="rtl"] .icon-directional { transform: scaleX(-1); }

Mark only arrows, chevrons and similar glyphs with that class. Logos and checkmarks must not flip.

Code must stay left to right. Set it explicitly, because the browser's bidi algorithm will otherwise reorder punctuation at the ends of lines — a command like npm run build -- --verbose rendered in an RTL paragraph can show its trailing dashes on the wrong side:

pre, code, kbd, samp { direction: ltr; unicode-bidi: isolate; }
pre { text-align: left; }

For URLs and file paths inside running text, wrap them in <bdi> or <code> so they are isolated from the surrounding right-to-left text. A Markdown renderer hook can do this for all inline code automatically.

Step 4: Fonts

Arabic script needs a font with Arabic glyphs; system fonts on all major platforms include one, and a system stack is the fastest option. If brand requires a web font, subset it: a full Noto Naskh Arabic variable font is about 330 KB as WOFF2, and a subset to the Arabic block plus basic Latin punctuation is about 96 KB. Preload only the subset used above the fold, and use font-display: swap with a metric-matched fallback as in Metric-Matched Fallback Fonts with size-adjust.

Arabic text also sets taller than Latin at the same font size. Increase line-height for the Arabic locale (1.8 instead of 1.6 worked here), or diacritics collide between lines.

Font bytes for three Arabic font strategies Horizontal bars of WOFF2 bytes. A full Noto Naskh Arabic variable font is 330 kilobytes. A subset to the Arabic block and basic punctuation is 96 kilobytes. A system font stack downloads nothing. Arabic font payload options Full Noto Naskh Arabic 330 KB Subset Arabic + punctuation 96 KB System font stack 0 KB
Sizes measured with the woff2 tool after pyftsubset; the system stack was chosen for body text and the subset for headings only.

Step 5: Test Both Directions in CI

Two cheap tests catch almost every RTL regression. First, a static check on the built output that every page under /ar/ has dir="rtl" on <html> and every <pre> is left to right. Second, visual regression screenshots of the main templates — home, article, section index, search results — in Arabic, compared against approved baselines.

A useful trick before any translation exists: build the English site with dir="rtl" forced on. Anything that does not mirror is a physical CSS property left behind. On this project the forced-RTL English build found 143 unmirrored declarations before the conversion and 3 after.

// tests/rtl.spec.ts
import { test, expect } from '@playwright/test';
for (const path of ['/ar/', '/ar/guide/install/', '/ar/search/']) {
  test(`rtl layout ${path}`, async ({ page }) => {
    await page.goto(path);
    await expect(page.locator('html')).toHaveAttribute('dir', 'rtl');
    await expect(page).toHaveScreenshot({ fullPage: true, maxDiffPixelRatio: 0.01 });
  });
}
Physical CSS declarations before and after conversion Vertical bars. The forced right-to-left English build found 143 physical declarations that did not mirror before conversion. After converting to logical properties 3 remained, all deliberate. After adding the stylelint rule, new ones were blocked at pull request time, so the count stayed at 3. Unmirrored declarations found by a forced-RTL build 143 Before conversion 3 After logical properties 3 Three months later with stylelint
Counted with a script that diffs element bounding boxes between dir=ltr and dir=rtl builds of the same pages.

Mixed-Direction Text in Practice

Real documentation in Arabic is full of English: product names, option names, error messages, version numbers. The Unicode bidirectional algorithm handles most of it, but a few patterns break reliably and are worth handling in templates rather than leaving to translators.

A sentence ending in an English term followed by punctuation — "شغّل الأمر npm install." — can render with the full stop on the wrong side of npm install, because the period is a neutral character and takes the direction of the text next to it. Wrapping the term in <code> with the isolate rule above fixes it; for plain text terms, <bdi> does the same without code styling. A Markdown extension that wraps backtick spans is enough for most sites, since technical terms are almost always marked as code already.

Numbers are left-to-right even in Arabic, but their separators depend on locale. Use Intl.NumberFormat at build time with the page locale to format figures in tables, and decide consciously between Western Arabic digits (0–9), which most technical audiences expect, and Eastern Arabic digits (٠–٩); new Intl.NumberFormat('ar-EG') produces the latter, 'ar' with numberingSystem: 'latn' the former.

Form inputs deserve a check as well. A search box on an Arabic page should accept Arabic text right to left, but a query for an English API name typed into it should display left to right. dir="auto" on the <input> lets the browser choose from the first strong character typed, which gives the right result in both cases. The same attribute is useful on user-generated content such as comments, where each item may be in either direction.

Measured Impact

MeasurePhysical CSS + RTL overridesLogical CSS
RTL-specific CSS rules140 (separate file)4 (icons, code)
CSS shipped to Arabic pages38 KB (base + overrides)31 KB
Visual regressions reported in first month91
LCP p75 on Arabic pages, throttled Moto G Power (Lighthouse)2.9 s with full web font1.9 s with system stack

Pitfalls & Rollback

  • Using direction: rtl in CSS instead of the attribute. Form controls and screen readers ignore it.
  • Flipping every icon. Logos, checkmarks and play buttons must not mirror.
  • Forgetting code direction. Punctuation jumps to the wrong end of commands.
  • Physical properties in third-party components. Search widgets and embeds may not support RTL; check them in the forced-RTL build.
  • Rollback: logical properties behave identically to physical ones in left-to-right pages, so the CSS conversion is safe to keep even if the Arabic locale is withdrawn. Removing the locale is a configuration change.

Conclusion

Right-to-left support is mostly a CSS decision made early. Set dir and lang on the root element per locale, write layout with logical properties, flip only directional icons, keep code left to right, and test with a forced-RTL build plus screenshots. The Arabic launch on this site needed four RTL-specific CSS rules instead of 140, and came in a full second under the LCP of the web-font version.

FAQ

Do I need a separate stylesheet for right-to-left languages?

Not if the CSS uses logical properties such as margin-inline-start, padding-inline and inset-inline-end. Those resolve according to the dir attribute, so one stylesheet lays out correctly in both directions. A separate RTL stylesheet is only needed for legacy CSS written with physical left and right properties.

Should code blocks be mirrored in an RTL page?

No. Code, command lines, file paths and URLs are left-to-right content. Set dir="ltr" on pre and inline code elements so they render in their natural direction inside a right-to-left page.

Which icons need to be mirrored?

Icons that imply direction or progression, such as back and forward arrows, next and previous chevrons, and list indentation, should flip. Icons of real-world objects, checkmarks, media play buttons and logos should not.

How do I test an RTL layout without reading Arabic?

Set dir="rtl" on an English build and look for anything that did not flip, then run visual regression screenshots of key templates in both directions. Pseudo-localization tools can also generate RTL text that is readable to developers.

Filed underHow-tos