Arabic font fallback and line height

The layout mirrors correctly, the direction attribute is set, and the Arabic still looks wrong: letters that should join stand apart, vowel marks are sliced off at the top of the line, and a product name in Latin script inside an Arabic sentence renders noticeably larger than the text around it.

Direction is a layout property. What the text looks like is a typography property, and it is decided by which font supplies each glyph and how much vertical room the line box gives it.

Five Arabic rendering symptoms and their causes Disconnected letters mean the font provides no Arabic shaping. Clipped vowel marks mean the line box is too short for the marks above the baseline. Colliding ascenders and descenders mean a line height tuned for Latin. A size jump between scripts means a fallback font with a different x-height. And unexpected numeral shapes mean the locale tag selected a different numbering system. Why an Arabic line looks wrong Symptom Cause Letters not joined disconnected glyphs a font without Arabic shaping Marks clipped above vowel marks cut off line-height set in a fixed unit Lines too tight ascenders and descenders collide a Latin-tuned line-height Latin text taller mixed runs jump in size fallback font with a different x-height Numbers look foreign Eastern Arabic numerals appear the numbering system in the locale tag
Only the first is a missing font. The rest are a present font being asked to behave like a Latin one.

Root cause: font selection is per character, and Latin defaults are tight

A browser resolves font-family per character, not per element. It walks the list and uses the first family that contains a glyph for that character. A stack naming a Latin-only family first and a generic fallback last therefore takes Latin glyphs from the intended font and Arabic glyphs from whatever the platform provides.

That platform font may be fine or may lack the shaping tables that join Arabic letters into words, which produces the disconnected look. It will almost certainly have different proportions from the Latin family beside it, which is why mixed runs appear to change size mid-sentence.

The vertical problem is separate and just as common. Arabic script places marks above and below the baseline, and fully vocalised text stacks several. A line height tuned for Latin — or worse, set in a fixed pixel value — gives no room for them, so marks are clipped by the line box above.

Font selection and shaping for a mixed-script line The browser walks the font-family list per character and uses the first family containing a glyph for it. A unicode-range descriptor restricts a family to the script it is meant for. Anything unmatched falls back to a system font. Shaping — joining Arabic letters and positioning marks — happens after the glyphs have been chosen. How a glyph is chosen font-family list first family with the glyph wins, per character per glyph unicode-range restricts a family to a script per subset System fallback whatever the platform has last resort Shaping engine joins and positions the chosen glyphs after selection
Selection is per character, which is why one line can end up in two fonts of different proportions.

A third, quieter issue is numerals. The digits rendered for a locale depend on the numbering system, and some Arabic locale tags select Eastern Arabic numerals. That is correct for those locales and surprising when it appears unintentionally, usually because a tag such as ar-EG-u-nu-arab was copied from an example.

Minimal reproducible example

/* Latin-first stack: Arabic glyphs come from wherever */
body {
  font-family: 'Inter', system-ui, sans-serif;
  line-height: 1.4;          /* comfortable for Latin, too tight for marks */
}
<p dir="rtl" lang="ar">مَرْحَبًا بِكَ في Acme</p>
<!-- marks clipped, Latin "Acme" larger than the Arabic around it -->

The fix: name the Arabic family, scope it, and give the line room

@font-face {
  font-family: 'Noto Sans Arabic';
  src: url('/fonts/noto-sans-arabic.woff2') format('woff2');
  unicode-range: U+0600-06FF, U+0750-077F, U+08A0-08FF, U+FB50-FDFF, U+FE70-FEFF;
  font-display: swap;
}

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+2000-206F;
  font-display: swap;
}

:root { --font-stack: 'Inter', 'Noto Sans Arabic', system-ui, sans-serif; }

body {
  font-family: var(--font-stack);
  line-height: 1.75;         /* unitless, so it scales with font-size */
}

[lang="ar"], [dir="rtl"] {
  line-height: 1.9;          /* extra room for stacked marks */
}

Two mechanisms do the work. unicode-range tells the browser which characters each family is responsible for, so the Latin font is never asked for an Arabic glyph and the Arabic font is downloaded only when Arabic characters are on the page. A unitless line height scales with the font size instead of pinning a box that marks then overflow.

Five steps for Arabic typography An Arabic family is named explicitly and placed before the Latin family for Arabic content. Families are scoped with unicode-range so a Latin font cannot supply Arabic glyphs. Line height is unitless and generous enough for marks above and below the baseline. The two families are chosen so their x-heights match. And testing uses fully vocalised text and mixed Arabic-Latin runs. Making Arabic set correctly 1 Name an Arabic family explicitly before the Latin one for Arabic content 2 Scope families with unicode-range so Latin never steals Arabic glyphs 3 Set line-height unitless and generous 1.7 or more, never a fixed px 4 Match x-heights across the pair or Latin runs will look larger 5 Test with vowel marks and mixed runs the two cases plain samples miss
Steps one and two are one idea: say which font handles which script, rather than letting the cascade guess.

Matching the two fonts to each other

Even with both families loading correctly, a mixed line can look uneven, because the two typefaces were designed independently.

The property that matters most is the relationship between the Arabic and Latin sizes. Arabic letterforms are typically drawn smaller relative to their em box than Latin ones, so an Arabic family set at the same font-size often looks smaller than the Latin beside it. Font families designed as pairs — a Latin and an Arabic released together — solve this by construction, and where a pair is not available, a small font-size-adjust or a per-script size step closes most of the gap.

/* If the Arabic reads small next to the Latin at the same size */
:lang(ar) { font-size-adjust: 0.52; }

The second property is weight. Not every Arabic family ships the same weights as its Latin counterpart, and a missing weight is synthesised by the browser — a smeared approximation that looks noticeably worse in a connected script than it does in Latin. Restricting the interface to weights both families genuinely have is usually better than accepting synthesis.

Loading two scripts without paying for both

Adding a second script family doubles the potential font payload, and unicode-range is what stops every reader paying for both.

The mechanism is precise: a browser downloads a font file only when a character in its declared range actually appears on the page. An English page therefore fetches the Latin file and never touches the Arabic one, and an Arabic page does the reverse. Declaring the ranges accurately is what makes that work — a family declared with no range is a candidate for every character, so it downloads everywhere.

Subsetting compounds the saving and introduces the risk mentioned above. A subset built by character coverage is safe; one built by an aggressive optimiser that strips layout tables produces a file that contains the glyphs and cannot join them. Where a build pipeline subsets automatically, it is worth rendering a sample of connected text from the output as a build check rather than trusting the tool.

The third lever is font-display. A connected script rendered in a fallback font during load looks substantially more wrong than Latin does — the letters do not join, so the fallback period is visibly broken rather than merely different. Using swap keeps text readable throughout, and preloading the family for the current locale removes most of the window entirely.

Preloading is the one decision that depends on the locale being known before the stylesheet resolves, which it is: the resolved locale is in the URL, so the document can preload exactly the family that page will use and nothing else.

Verification

Automated checks catch the mechanical failures; the rest needs eyes.

test('Arabic text is rendered by the Arabic family', async () => {
  await page.goto('/ar/pricing');
  const font = await page.evaluate(() => {
    const el = document.querySelector('[lang="ar"], p')!;
    return getComputedStyle(el).fontFamily;
  });
  expect(font).toMatch(/Noto Sans Arabic/);
});

test('vocalised text is not clipped', async () => {
  // A line box shorter than the rendered glyphs means marks are cut.
  const clipped = await page.$$eval('p[lang="ar"]', (els) =>
    els.filter((el) => el.scrollHeight > el.clientHeight + 1).length);
  expect(clipped).toBe(0);
});

Beyond that, a screenshot comparison across a fully vocalised sample and a mixed Arabic-Latin sentence is the check that actually finds proportion problems — the sweep described in screenshot diffing RTL layouts in CI.

When to escalate

If letters still do not join, the font file may be a subset without shaping tables. Subsetting tools can strip the tables that make a connected script work, and the fix is to subset by character range rather than by aggressive optimisation.

If the correct font loads and the text still looks wrong, check for a font-synthesis effect: a missing weight or italic is being approximated. Arabic has no italic tradition, so a synthesised oblique is always wrong there.

If numerals render in an unexpected system, inspect the locale tag reaching the formatter — a -u-nu- extension changes the digits, and it is inherited from whatever tag was passed rather than from the direction.

FAQ

Should Arabic be a separate font stack or one stack for everything?

One stack with unicode-range scoping is simpler and performs better: the browser downloads only the families whose ranges appear on the page. Separate stacks per language require knowing the language at the element level, which is fragile for mixed content.

How much line height does Arabic need?

More than Latin, and the amount depends on whether text is vocalised. A unitless 1.7 is a reasonable default for unvocalised UI text, and fully vocalised passages want closer to 2. Setting it unitless is more important than the exact number, because it then scales with every size step.

Does this apply to Hebrew, Persian and Urdu too?

The vertical and font-selection issues do. Shaping intensity differs: Persian and Urdu use the Arabic script with additional letters and Urdu in the Nastaliq style is considerably more demanding of a font. Hebrew does not join letters but does place marks, so the line-height point applies.

Are variable fonts a problem here?

No, and they help: a variable Arabic family covers weights without synthesis, which is exactly the failure mode that looks worst in a connected script. The unicode-range scoping works identically.

Should the interface use a smaller or larger base size for Arabic?

Adjust the font rather than the base size where you can. Changing the base size for one language cascades into every spacing value derived from it, so a layout tuned in Latin drifts. A per-script size adjustment, or a family pair designed to sit together, keeps the rest of the system stable.

What about right-to-left text inside a left-to-right interface?

That is a bidirectional isolation question rather than a font one: a short Arabic run inside an English sentence needs isolation so the surrounding punctuation stays put. The font stack still applies, since the Arabic characters will be resolved per character exactly as described above.

Part of RTL & Bidirectional Layout Engineering.