Skip to the article

Typography Published Reading time 9 min

Why your Arabic font is setting your English text

The file almost certainly contains Latin — Zain carries 166 codepoints in U+0041 to U+024F, Cairo 291 — and font matching runs per codepoint, so the first family in the stack holding the glyph wins it. This site shipped that failure and its exact opposite at the same time, in three languages.

In short

Because unicode-range is not a subsetter and font matching runs per codepoint. If the Arabic file contains Latin glyphs — Zain ships 166 in U+0041 to U+024F, Cairo 291, and all three Arabic families this site has used carry the full 95-codepoint printable ASCII set — then any rule putting that family first in the stack wins every Latin character too. Declaring a unicode-range on the Arabic faces fixes it: the file is not changed, only the set of codepoints it is permitted to answer for. The opposite failure has the same root cause. Scope every Arabic rule on the document element and the one Arabic word inside an English page gets no Arabic face at all, falling through to the system naskh or to tofu.

What to take away

  1. unicode-range answers one question — may this face serve this codepoint — and it answers it per character, after the face has already been chosen by weight and style.
  2. Count your Arabic font’s Latin coverage before claiming it has none: Zain has 166 codepoints in U+0041 to U+024F, Cairo 291, and both carry the full printable ASCII set.
  3. Restricting a family creates a new class of codepoints it may no longer paint, beginning with the space between two words, so every stack naming it needs a fallback behind it.
  4. Giving an Arabic face your Latin family name only works if every other descriptor matches, because that is the spec’s condition for treating the two as one composite face.
  5. A rule scoped on the document element is a claim about the page, and a language switcher is by definition a run whose language differs from the page’s.
  6. Verify with CSS.getPlatformFontsForNode or the Rendered Fonts block, never with computed style, which reports the stack you wrote rather than the face that painted.

arabic.css:95 · Zain 166 · Cairo 291

Why is my Arabic font rendering my English text?

Because the font file is not what its name suggests. Open any Arabic webfont with fontTools and count the codepoints it declares between U+0041 and U+024F: Zain, the face this site ships now, has 166; Cairo, the face it replaced, has 291; Noto Kufi Arabic has 248. All three carry the complete 95-codepoint printable ASCII set on top of that. An Arabic font containing no Latin at all is the exception rather than the rule.

Font matching then does the rest, and it does it per character rather than per run. For each codepoint the browser walks the font-family list and stops at the first family holding a glyph for it — there is no step anywhere in the algorithm that asks what script the character belongs to. Put an Arabic family first in a blanket rule and it wins the Latin too, silently, because the result is legible and only slightly wrong.

This repository said otherwise for years. The header of src/styles/locale/arabic.css gave the reason for putting Cairo first in every stack: the files ‘carry only Arabic glyphs — Latin falls through to Quicksand and the site’s own identity holds for names and code.’ It was measured false. Every project name, every HTML and CSS chip and the string Google AI Essentials on the Arabic pages had been rendering in Cairo, 381 Latin runs of them, for the life of the build.

Coverage counted with fontTools 4.60.2 over the shipped woff2 files, 26 August 2026. Cairo and Noto Kufi were read out of git history; the Zain swap removed them from the working tree.
Font Printable ASCII, U+0020 to U+007E Arabic, U+0600 to U+06FF Latin, U+0041 to U+024F
Zain Regular, Bold, ExtraBold 95 109 166
Cairo Regular, SemiBold 95 102 291
Noto Kufi Arabic Bold 95 256 248
Quicksand Regular 95 0 357

The last row of the table is the one that matters twice. Quicksand carries zero Arabic codepoints, which is why an Arabic run whose stack names no Arabic family has nowhere to go — that is the second half of this article, and the same site shipped it at the same time, in the opposite direction.

CSS Fonts 4 §4.5 · initial U+0-10FFFF

unicode-range is not the file, not the family, and not the document

The descriptor answers exactly one question — may this face be used for this codepoint — and CSS Fonts 4 gives it two jobs in a single paragraph. The union of the declared ranges is ‘a hint for user agents when deciding whether or not to download a font resource for a given text run’, and then, much harder: ‘User agents must not download or use the font for codepoints outside this set.’ The initial value is U+0-10FFFF, so a face with no range declared is allowed to answer for the whole of Unicode, which is what every face on this site did until August.

It is not the file’s contents, and the spec goes out of its way to say so: ‘the effective character map is the intersection of the codepoints defined by unicode-range with the font’s character map.’ The four Zain faces here declare 1,264 codepoints and cover 252 of them, 19.9 per cent. Three of the declared blocks are completely empty in the file, and so is U+200F, the right-to-left mark. Nothing breaks, because intersection is the rule and the spec blesses declaring broad ranges without checking the font.

It is not subsetting either, and that confusion costs more than a vocabulary point. Subsetting is a build step that rewrites the font, and on this project it was measured to destroy the script: Arabic dots are separate glyphs composed at render time, and a subsetter that drops what it judges unreachable drops them. The decision was taken for Cairo and carries to Zain unchanged. These files are full fonts on purpose, with a restricted range in front of them.

What the four Zain faces declare, against what src/assets/fonts/zain/regular.woff2 actually contains.
Declared range Codepoints declared Codepoints covered
U+0600-06FF, Arabic 256 109
U+0750-077F, Arabic Supplement 48 0
U+0870-088E, Arabic Extended-B 31 0
U+08A0-08FF, Arabic Extended-A 96 0
U+200F, right-to-left mark 1 0
U+FB50-FDFF, Presentation Forms-A 688 54
U+FE70-FEFF, Presentation Forms-B 144 89
All seven, as declared on every face 1,264 252

Two codepoints run the other way — present in the file, absent from the range. U+0020 and U+0030 are both in Zain’s character map and neither falls inside the declared union, so the browser is forbidden from using Zain for a space or a zero. That is deliberate, it is why the gap between two Arabic words resolves to Quicksand, and it has a consequence in the last section that nobody here has measured yet.

nav-lang.css · 5 faces · 70 routes

Why the same technique failed on the site this one replaced

The predecessor to this site tried unicode-range first and concluded it did not work. It hung Arabic-range faces under the site’s own family names — Cairo under Quicksand, Cairo again under Quicksand Label, Noto Kufi Arabic under Quicksand Display — precisely to avoid a cascade fight with a 183 KB shared stylesheet. Five faces, on all 70 routes. Its own comment records the outcome: ‘Chromium kept resolving Arabic runs through the full-range Quicksand faces and falling to the system naskh.’

The measurement was right and the conclusion drawn from it was wrong. unicode-range is consulted after a face has already been chosen by style, not before, and faces collapse into one composite face for that step only when their other descriptors are identical: ‘A group of faces defined via @font-face rules with identical font descriptor values but differing unicode-range values are considered to be a single composite font face for this step.’ These were not identical. The site’s Latin faces declared weight ranges of 200 300, 400 and 500 900; the Arabic ones 100 500 and 501 900.

So weight-narrowing left more than one face standing, and the spec then hands the decision away. The user agent ‘must choose a single font from the matching set’, and ‘the choice of which font to choose can differ between multiple user agents and multiple operating system platforms’. When it chose a Latin face the Arabic glyph was not there, and the next sentence shuts the door: ‘Glyphs from other faces in the family are not considered.’ The family is abandoned whole, the next family name is tried, and at the end of the list the browser runs installed-font fallback, whose result ‘can vary across user agents’. That is the system naskh.

The arrangement that produced the failure, reconstructed from the Golden Master’s nav-lang.css. Two rules share a family name and disagree on every other descriptor.
/* the Arabic face, hung under the site’s own family name */
@font-face {
  font-family: 'Quicksand';
  src: url('Cairo-SemiBold.ar.v2.woff2') format('woff2');
  font-weight: 501 900;
  unicode-range: U+0600-06FF, U+0750-077F, U+08A0-08FF,
    U+FB50-FDFF, U+FE70-FEFF, U+200C-200F;
}
 
/* and, elsewhere in the same 183 KB sheet, the real Quicksand */
@font-face {
  font-family: 'Quicksand';
  src: url('Quicksand-SemiBold.woff2') format('woff2');
  font-weight: 500 900;
  /* no unicode-range: the initial value is all of Unicode */
}
 
/* the descriptors differ, so these are two faces, not one */
/* composite face. Style narrowing runs first and may keep both. */

The same repository ships the counter-example, and it is worth putting beside the failure. Ten demo font directories carry Google Fonts’ own latin and latin-ext split, generated by tools/localize-demo-fonts.mjs: 109 faces kept, 100 other-script faces omitted. Those faces do have identical descriptors apart from the range, so they form one composite face and the outcome is deterministic in every engine. Google splits one design across ranges. The Golden Master hung a different design under an existing name at a different weight, which is the one arrangement the composite rule does not cover.

legacy 11-route probe · comment said 2.5 KB

One Arabic word cost 50,096 bytes on every English page

The second bill from that arrangement was measured with a fetch probe across eleven routes of the old site. Cairo-SemiBold.ar.v2.woff2, 50,096 bytes, was never preloaded anywhere and was fetched on all eleven routes — including every English and German one. It arrived with initiatorType css between 610 and 640 ms on five ordinary routes, at 1,118.4 ms on the 404 page and at 1,464.2 ms on the homepage.

The trigger was one string: the language switcher’s Arabic label, four characters inside a nav menu nobody had opened. The comment sitting beside the code claimed that English and German pages download about 2.5 KB once for it. The architecture record states the discrepancy plainly — off by roughly twenty times — and adds the part that makes it a defect rather than a curiosity: anyone optimising against the comment rather than the measurement will not find the 50 KB.

Font fetches across an eleven-route probe of the site this one replaced. Every figure here is a measurement of the old build, not of this one.
File Preloaded on Fetched on Bytes
Quicksand-Regular.woff2 en, de, ar 11 of 11 30,844
Quicksand-Medium.woff2 en, de 10 of 11 30,784
Quicksand-SemiBold.woff2 en, de 11 of 11 31,084
Quicksand-Light.woff2 never 0 of 11 29,000
Cairo-Regular.ar.v2.woff2 ar 1 of 11 49,084
Cairo-SemiBold.ar.v2.woff2 never 11 of 11 50,096
NotoKufiArabic-Bold.ar.v2.woff2 ar 1 of 11 60,720
All seven files 281,612

Whether a hidden string costs anything is a question about boxes, not about visibility. The old menu panel’s base rule in the shipped stylesheet carried the open clip-path; the closed state, display none and a flattened ellipse, was written as an inline style by the runtime at boot. The panel was therefore laid out at first paint and hidden afterwards, and a laid-out text run is a text run the download rule applies to.

This build declares the closed state statically — the panel carries display none in the shipped sheet, described in its own comment as the settled state and the emitted state. That should mean no boxes and no fetch until the menu opens. It has not been measured on this build, and the previous build measured the equivalent claim false, so treat it as a mechanism with a check attached rather than as a result. Open the Network panel, filter to Font, load an English page, then open the menu and watch.

arabic.css:104-146 · 4 faces, one range

The range as shipped, and the hole it opened the same day

The rule went in with the typeface swap, and the reason was drift. Zain’s Latin is a different design from Cairo’s: cap height 69.5 per cent of the em against Cairo’s 69.1, but x-height 46.0 against 50.0 — 8 per cent smaller at essentially the same cap. An undetected capture would have got visibly worse under the new face, so the change that introduced Zain also introduced the declaration that stops Zain answering for Latin at all.

Four faces, two synthetic families, one identical range on all of them, split at weight 650 so the existing 600 and 700 promotions select the right cut without a single rule being edited. The payload is byte-neutral to within a kilobyte: 161,096 bytes of Zain against the 159,900 the two families it replaced cost, a difference of 1,196 bytes.

src/styles/locale/arabic.css, abridged. The mono rule at the bottom is the deliberate inverse: Zain sits after the monospace faces, not before, so Latin and figures keep their mono and only Arabic glyphs borrow Zain.
@font-face {
  font-family: 'Zain AB';
  src: url('../assets/fonts/zain/regular.woff2') format('woff2');
  font-weight: 100 500;
  font-display: swap;
  size-adjust: 112%;
  unicode-range:
    U+0600-06FF, U+0750-077F, U+0870-088E, U+08A0-08FF,
    U+200F, U+FB50-FDFF, U+FE70-FEFF;
}
 
html[lang='ar'] body,
html[lang='ar'] body * {
  font-family: 'Zain AB', Quicksand, Arial, sans-serif;
}
 
/* Zain LAST here, on purpose: figures and identifiers keep the mono */
html[lang='ar'] :is(code, pre, .cdx-code) {
  font-family: ui-monospace, menlo, 'Zain AB', 'Courier New', monospace;
}

Adding a range immediately creates a class of codepoints the family may no longer paint, and the first of them is the space between two Arabic words. Every stack naming Zain now needs a family behind it covering everything the range excludes — and one of them did not have one. The display stack omitted Quicksand, so the space inside a two-word Arabic heading, the ampersand in another and any digit landed on Arial: two Arial glyphs inside the largest type on the page, found with CSS.getPlatformFontsForNode.

The measurement proving the range worked is the same measurement that found the hole it opened. After the fix, every Latin glyph on the Arabic pages resolves to Quicksand or Menlo and none of them to Zain.

The two size-adjust values in that listing, 112 per cent for prose and 103 per cent for display, come out of a separate measurement — the tooth-and-alef ratio.

P11-03 · found and fixed 2026-08-26

The opposite failure: an Arabic word with no Arabic face

The language switcher prints all three language names in their own languages on all three documents, and that is the point of it: a reader who cannot read the current page still recognises their own. The markup was already telling the truth. The Arabic anchor carries hreflang, lang and dir, and the string is declared once and listed as shared in all three content bundles, so no locale translates it.

What it did not have was a face. Every Arabic rule in the stylesheet was scoped on the document element, which is a fact about the page rather than about the run of text. On the English and German documents the label inherited the Latin label token instead — Quicksand Label, then Arial, then sans-serif — and Quicksand contains exactly zero codepoints in U+0600 to U+06FF. The browser exhausted the stack and ran installed-font fallback.

Measured on macOS in both WebKit and Chromium: the label rendered in the platform’s naskh beside four Quicksand labels, at a different weight, a different colour density and a different optical size. On a device with no Arabic system face it renders as tofu. Nothing about the run was wrong except the CSS, and the CSS had asked a question about the document when the question was about four characters.

العربية
The string itself, declared once and listed as shared in the English, German and Arabic bundles, so all three documents print it in Arabic rather than translating it. Correct from the start.
the markup
src/components/site-nav.html sets hreflang, lang and dir on the anchor, and asks the splitter for lines rather than words and characters. Correct from the start.
the stylesheet
Every rule that could have supplied an Arabic face was scoped on the document element. On the two Latin documents not one of them matched, and the run fell out of the stack entirely.
Three layers of the same word, and the single layer that was wrong.

It survived a purpose-built RTL gate, a trilingual content register and a build that enforces parity across three locales, because none of those asks the English page a question about Arabic. It was found by reading the file’s own header and disbelieving it.

The switcher’s Arabic anchor leans on the user-agent stylesheet for its bidi handling on the Latin pages, which is the shape of gap that logical properties do not close.

utilities beats components, no !important

Scope on the language, not the document

The fix is two rules, and the second half of the first one is load-bearing rather than belt and braces. The family goes on the language attribute and on its descendants, because components/button.css sets font-family from a token on the span inside the anchor — and inheritance never beats a declaration on the element itself. A rule on the anchor alone would lose on the only element that holds the ink.

It wins without !important because of layer order. src/styles/order.json declares reset, tokens, base, layout, components, sections, utilities, overrides; locale/arabic.css sits in utilities and button.css in components, two layers earlier, and a later layer beats any specificity an earlier one can write. The Arabic blanket beside it does carry one !important, for letter-spacing, and it is the only justified blanket of its kind in the project. This rule needs none.

  1. Name the language, not the page Replace the document-scoped selector with one on the language attribute for anything that must apply to an Arabic run wherever it appears. The document scope stays for everything genuinely about the document, such as direction.
  2. Reach the element holding the ink Add the descendant half of the selector. A child declaring its own font-family will not inherit yours, so the rule has to match that child too.
  3. Let the layer settle it Put the rule in a layer after the one that declared the token. No specificity fight, no !important, and the same mechanism keeps a section sheet from overriding a locale correction.
  4. Zero the tracking The body sets letter-spacing to minus 0.005em and the label span declares none of its own, so the Arabic word inherited it on the Latin pages. Arabic never letter-spaces: tracking breaks the joined script, the letters stop touching and the word stops being a word.
  5. Open the line box and the clip window together The label is set at line-height 1 by the button component and clipped at 94 per cent of its box. Zain’s content box at size-adjust 112 per cent is 1.859em, so at line-height 1 the half-leading is negative and the ink runs past the cut. The rule sets 1.55 and moves the window to 114 per cent.
  6. Switch to the language pseudo-class for the metrics The metrics rule uses :lang(ar) rather than the attribute selector. Content language is inherited by descendants and matched by extended filtering, so it also covers ar-EG and ar-Arab-EG, which an exact attribute match does not.
The five parts of the fix, in the order they were needed. The face alone changed the glyphs and left three inherited Latin properties wrong.

The clip arithmetic reproduces exactly, which is how the numbers in that rule were chosen rather than guessed. Zain’s hhea gives ascent 869 and descent minus 459 over 800 units per em; at size-adjust 112 per cent that is 1.2166em and 0.6426em, a content box of 1.8592em. At line-height 1 the half-leading is minus 0.4296em and the baseline sits 0.7870em below the top of the line box, which puts the deepest ink of the label at 1.2505em against a cut at 0.9400em. At 1.55 with the window at 114 per cent the ink lands at 1.5716em, the window at 1.7670em and the hover twin at 1.8454em, in that order. Glyph depths are measured on isolated forms.

The file states the invariant as twin below window, window below ink, and it records what happens when it breaks. On the old site’s footer link the ink ran five pixels past the cut, a final yeh lost its dots and the word rendered as a different word — a misspelling of the owner’s own writing. It was fixed on the Arabic pages and had been live on the English and German ones ever since, on the single Arabic word those documents contain.

The 1.55 in that rule is a clip-window number rather than a collision number, and the difference between the two is the measured floor for Arabic line-height.

split-registry.js:121-190 · read once

The same mistake, one layer down, in the JavaScript

The split registry reads the document’s language exactly once, at boot, and its comment gives a decent reason: lang and dir are written by the build onto the document element and never change afterwards, so asking per element would be 55 readings of one fact and 55 chances to read it differently. It then uses that single reading to decide whether a run may be split into characters.

On the Arabic pages that is right. On the English and German ones it is the stylesheet’s error in another language: the document is en or de, so the Arabic label would be split per character. Character splitting is not cosmetic in a joined script. The reveal component makes each character an inline-block, which it must, because transform does not apply to a non-replaced inline box — and an atomic box per letter is a box the shaper cannot join across. The word comes out as isolated forms, which is to say spelled wrong.

The markup avoids it by hand. That one span asks the splitter for lines while the English and German labels ask for words and characters, and the module’s own comment concedes the point: the component file had already reached this conclusion by hand for the one label somebody noticed.

Four failures across this site and its predecessor. Three of them are the same mistake: a question asked about the document when the question was about the run.
Failure Where What the browser did What it cost
Too much capture the Arabic pages, Cairo first with no range answered every Latin codepoint out of the Arabic file 381 Latin runs in the wrong typeface
Too much capture, on download the old English and German pages kept two faces standing and picked one arbitrarily 50,096 B on 11 of 11 routes
Too little capture the English and German pages, every rule on the document exhausted the stack and ran installed-font fallback 1 word in the system naskh
Too little capture, in script the English and German pages, the split registry would split a joined script into atomic inline boxes 1 word spelled wrong on screen

The automated gate cannot see three of the four rows below. tools/golden/rtl.mjs walks every text node and asserts that every Arabic run asks for Zain and every Latin run keeps a Latin fallback — on Arabic routes only. It loads the English homepage as a control for the splitter, the header, the diptych and the rail, and never puts the font question to it. A grep across tests and contract checks still returns nothing for the new language-scoped rule. A purpose-built RTL gate ran green while the defect lived on the two most-visited documents in the product.

Why an inline-block per letter breaks an Arabic word, and what a splitter has to do instead, is the joined-script splitting problem.

glyphCount, not fontFamily

How do you prove which face actually won?

Computed style will not tell you. It reports the stack you authored, and this repository’s own gate says so in a comment on the branch where it gives up: a Latin-only run inside an Arabic document may legitimately ask for the Arabic family first, unicode-range is what routes it onward to Quicksand, and a computed style cannot see that. What the gate can assert is the weaker, still useful property — that no run asks for a family with no Latin fallback behind it.

CSS.getPlatformFontsForNode over the Chrome DevTools Protocol is what settles it. It returns, per node, the platform families that actually painted the text, whether each was downloaded or resolved locally, and how many glyphs each contributed. That is what found the two Arial glyphs inside a display heading, and what confirmed afterwards that no Latin glyph on the Arabic pages is Zain. By hand it is the Rendered Fonts block at the bottom of the Computed pane.

Zain Regular, at 112 per cent 0.7028 em
Quicksand Regular, at 94.2 per cent 0.5605 em
Cairo Regular, at 100 per cent 0.5600 em
Quicksand Medium, at 93.0 per cent 0.5589 em
The advance of U+0030 in each face, in ems, after size-adjust. This is the number 1ch resolves to.Measured with fontTools over the shipped files. Bars are to scale against Zain at 0.7028em.
CSS.getPlatformFontsForNode
Returns familyName, isCustomFont and glyphCount for every platform font used to render a node’s text. The only one of the four that answers which face won.
Rendered Fonts, Computed pane
The same data by hand, at the bottom of the pane. Several families listed against one element means fallback happened inside it.
the Network panel, filtered to Font
A different question: whether the file was fetched at all. A file that was fetched may still have lost every codepoint to a family in front of it.
document.fonts.check()
A trap. MDN says it is not designed to verify whether a specific font style can be rendered, and its text argument defaults to a single space — against a face whose range excludes U+0020 the answer is vacuous. Pass the real string.
Four tools, and the question each one can and cannot answer.

One consequence of this range has not been measured here, and it is worth a check on any site using ch for measure. The ch unit is the advance of U+0030 in the font used to render it, and U+0030 is outside the Zain declaration, so the browser must not use Zain for it and Quicksand supplies it instead. Going from Cairo to Quicksand moved 1ch by 0.09 per cent. Had Zain been left full-range, 1ch would have grown by 25.4 per cent, across more than twenty ch-based measures in this stylesheet.

The same clause has a quieter effect. The first available font — what ex, cap and lh resolve against — is defined as the first font for which U+0020 is not excluded by a unicode-range, and U+0020 is outside the Zain range too, so Zain can never be the first available font on any document here. The advances above are measured; the rendered column widths on the Arabic pages before and after are not. Compare them yourself before assuming it either way.

The trilingual build every one of these numbers was read out of is one of the ten concept sites.

Questions

Does unicode-range make my font file smaller?

No. It changes nothing about the file. CSS Fonts 4 calls the union of declared ranges a hint for user agents deciding whether or not to download a font resource for a given text run, and MDN puts the outcome plainly: if the page uses at least one character in the range, the whole font is downloaded. The thing that makes a file smaller is subsetting, a build step that rewrites the font — and on this site subsetting was measured to break Arabic mark composition, because the dots are separate glyphs composed at render time and the subsetter dropped what it judged unreachable.

Does my unicode-range have to match what is really in the font?

No, and the spec says so outright: the effective character map is the intersection of the codepoints defined by unicode-range with the font’s character map. This site declares four Arabic blocks of which three are completely empty in the file, plus a right-to-left mark the font does not carry either — 1,264 codepoints declared against 252 covered. Nothing breaks. What you must not do is the reverse, declaring a range narrower than the codepoints you need painted, because the browser is then forbidden from using the face for them.

Why does the browser pick the wrong face when I hang an Arabic font under my Latin family name?

Because unicode-range is consulted only after a single face has been chosen by style. Faces collapse into one composite face for that step only when their other descriptors are identical. Declare your Arabic face at font-weight 100 500 and your Latin faces at 200 300, 400 and 500 900 and they are not identical, so weight-matching can leave several standing — and the spec then permits the user agent to choose one arbitrarily and to differ from other user agents. If it picks the Latin face, the Arabic glyph is missing, glyphs from other faces in the family are not considered, and the family is abandoned whole. Google Fonts’ latin and latin-ext split works because its faces do have identical descriptors.

My English page shows one Arabic word in the wrong font. Why?

Almost certainly because your Arabic styles are scoped on the document rather than on the run. Every rule in this site’s Arabic sheet was scoped on the document element, which is a fact about the page. The language switcher’s own Arabic label — printed on all three documents so a reader who cannot read the current page can still find their own — therefore inherited the Latin label token, which resolves to Quicksand and then Arial. Quicksand contains zero Arabic codepoints, so the browser exhausted the stack and ran installed-font fallback, whose result the spec says can vary across user agents. On a device with no Arabic system face it is tofu.

Should I use the lang attribute selector or the language pseudo-class?

The pseudo-class is the more correct of the two and is usually what you want. It matches the element’s content language, so it also matches descendants inheriting it, and it uses extended filtering, so :lang(ar) matches ar-EG and ar-Arab-EG as well. An attribute selector matches only the element literally carrying the attribute with that exact value. This site uses the attribute selector plus an explicit descendant half for the family, because the child element declares its own font-family and inheritance never beats a declaration, and the pseudo-class for the metrics.

Sources

Measured in this repository

  • src/styles/locale/arabic.css The four Zain faces and their single unicode-range, the stacks that consume them, the clip-window arithmetic, and the language-scoped rule that fixes the Arabic run inside a Latin document.
  • src/assets/fonts/zain/ The three cuts every coverage figure and every advance above were counted out of, shipped with their SIL Open Font Licence.
  • docs/evidence/legacy-forensics/a10-css.md The predecessor’s five-face arrangement, its own account of why it failed, and the eleven-route fetch probe behind the 50,096 byte row.
  • tools/golden/rtl.mjs The RTL gate that asserts font resolution on Arabic routes only, and the comment recording exactly what a computed style cannot see.
  • src/runtime/animation/split-registry.js The single reading of the document’s language at boot, and the character-splitting decision that follows from it.

Checked against

Alaa Abbod

Written by

Alaa Abbod

Creative Developer — Herne, Germany

Designer and developer who builds accessible websites, mobile apps, online stores and visual identities as one job, by hand. This site is published in English, German and Arabic from one source, which is where most of these questions came from.

Please rotate your device,
This is a vertical build.