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
- A multilingual site with per-locale routing, as in i18n Routing in Astro or Multilingual Sites with Hugo.
- A stylesheet you can refactor (Tailwind 3.3+ also provides logical utilities such as
ms-4andpe-2). - Playwright or a similar tool for screenshots, as in Visual Regression Testing on Preview Deploys.
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:
| Physical | Logical |
|---|---|
margin-left / margin-right | margin-inline-start / margin-inline-end |
padding-left: 1rem; padding-right: 1rem | padding-inline: 1rem |
left: 0 | inset-inline-start: 0 |
text-align: left | text-align: start |
border-left | border-inline-start |
float: left | float: inline-start |
border-top-left-radius | border-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"] }] }
}
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.
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 });
});
}
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
| Measure | Physical CSS + RTL overrides | Logical CSS |
|---|---|---|
| RTL-specific CSS rules | 140 (separate file) | 4 (icons, code) |
| CSS shipped to Arabic pages | 38 KB (base + overrides) | 31 KB |
| Visual regressions reported in first month | 9 | 1 |
| LCP p75 on Arabic pages, throttled Moto G Power (Lighthouse) | 2.9 s with full web font | 1.9 s with system stack |
Pitfalls & Rollback
- Using
direction: rtlin 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.
Related
- Parent: Internationalization for Static Sites — the full multilingual setup.
- Self-Hosting Google Fonts to Eliminate Layout Shift — serving script fonts yourself.
- Subsetting Variable Fonts for Faster First Render — shrinking Arabic fonts.
- Evaluating SSG Accessibility Defaults — lang and dir as accessibility requirements.
- Language Detection and Redirects at the Edge — sending Arabic readers to /ar/.
Filed underHow-tos