There is a bug that shows up on no dashboard. It throws no exceptions and breaks no build. It triggers the day your product enters a market that does not write in the Latin alphabet, and what degrades is not an endpoint: it is the brand.

The mechanism is this. Almost every brand font (Roboto, Suisse Intl, any corporate grotesque) covers Latin and little else. When the browser’s font matching algorithm hits an Arabic, Chinese or Hebrew character with no glyph in the declared font, it walks down the stack, finds nothing, and lands on whichever system fallback it sees fit: a different typographic personality, a different optical size, a different x-height. Arabic renders tiny next to Latin. CJK falls back to whatever is there. Everything “works”. And the interface reads as careless in exactly the market where trust is hardest to earn.

The usual answers cost money and calendar time. This is the one I implemented in my own design system, and its balance sheet fits on one line: nine writing systems covered with zero kilobytes downloaded, zero new licences and no changes to any component.

The result, before the technique

The same welcome screen, with the Latin brand font untouched. On the left, what the browser delivers by default; on the right, what the design system delivers:

Before and after comparison of a list of greetings in Spanish, Arabic, Chinese, Japanese, Korean, Hebrew, Thai and Hindi. In the before, the non-Latin text renders noticeably smaller than the Spanish; in the after, every language presents at the same visual size.

There is no error in the “before”: it is the standard behaviour of any site whose brand font only covers Latin. Each script falls to a different fallback and renders smaller than Latin at the same font-size, because every writing system draws its glyphs at a different proportion of the em square. In the “after”, each script uses its platform’s native grotesque, optically calibrated to match the visual height of the Latin.

The effect is clearest where it actually hurts, in product components. Two notifications, in Arabic and in Japanese:

Before and after comparison of two notification cards, one in Arabic and one in Japanese. In the before, the text looks small relative to the interface elements; in the after, headings, body and buttons present a balanced typographic hierarchy.

And in the most treacherous case, the one almost nobody tests: non-Latin text embedded inside a Latin sentence. Names of cities, of people, of brands.

Before and after comparison of a Spanish paragraph that includes place names in Arabic, Chinese, Japanese, Korean, Hebrew and Thai in brackets. In the before, the place names look smaller than the surrounding text; in the after, they sit at the same visual size.

No :lang()-based solution helps here: language matching operates at element level, and these glyphs share a text node with the Latin. The solution has to operate where the problem operates, character by character.

What the usual routes cost

When an organisation runs into this, three options are usually on the table:

OptionDirect costHidden costTimeline
Commission or licence Arabic/CJK cuts of the brand fontLicences per script and per weight; often five figuresContract management per marketMonths
Load webfonts (Noto and similar) per languageHosting/CDNMegabytes per CJK script, worse LCP/CLS in exactly the new markets; subsetting pipelineWeeks
Do nothing£0Broken brand and relative illegibility in every non-Latin market

The fourth option is the one this article documents: engineering on top of what every operating system already ships. macOS, Windows, iOS and Android include very good native grotesques for each writing system (PingFang, Geeza Pro, Yu Gothic, Nirmala UI, the Notos), drawn and maintained by the platforms themselves. There is nothing to buy and nothing to download. What is needed is knowing how to invoke them with typographic judgement. And that ships in the same release, with no new budget and no performance penalty.

The solution: three architecture decisions

1. A dedicated family, ahead of the brand font

I declare a single family, p4-typo-font-family-non-latin, made up of one @font-face per script. Each face fences off its territory with unicode-range and resolves against system fonts with local():

@font-face {
  font-family: 'p4-typo-font-family-non-latin';
  src: local('PingFang SC Regular'), local('PingFangSC-Regular'),
    local('Microsoft YaHei'), local('NotoSansSC-Regular'), local('Noto Sans SC');
  size-adjust: 110%;
  unicode-range: U+2E80-2EFF, U+3000-303F, U+3400-4DBF, U+4E00-9FFF, U+F900-FAFF, U+FF00-FFEF;
}

@font-face {
  font-family: 'p4-typo-font-family-non-latin';
  src: local('Geeza Pro Regular'), local('GeezaPro'),
    local('Segoe UI'), local('Tahoma'),
    local('NotoSansArabic-Regular'), local('Noto Sans Arabic');
  size-adjust: 140%;
  unicode-range: U+0600-06FF, U+0750-077F, U+08A0-08FF, U+FB50-FDFF, U+FE70-FEFF;
}

And I put it ahead of the Latin font in the family design tokens:

--p4-typo-font-family-base: 'p4-typo-font-family-non-latin', var(--p4-typo-font-family-roboto);
--p4-typo-font-family-alt:  'p4-typo-font-family-non-latin', var(--p4-typo-font-family-quicksand);

What makes this work is plain mechanics from the CSS fonts specification. Font matching runs per character: for each glyph, the browser walks the stack and only considers a face if its unicode-range contains that character. Since the non-Latin family declares non-Latin ranges exclusively, Latin text passes through it as if it were not there and lands on the brand font. An Arabic or Han character, on the other hand, matches its dedicated face and wins because it comes first. Glyph allocation is automatic, one by one, without a single line of logic.

The operational consequence is the one that matters: the rest of the CSS never finds out. Hundreds of components go on consuming the same token, with no per-language branches, no :lang() selectors scattered about, no adoption cost for the teams. The whole integration was two tokens.

2. local() instead of webfonts: the cost disappears

src uses local(), not url(): the browser resolves against fonts already installed on the device. There is no download, no licence, no CDN, no FOUT and no layout shift from late fonts. Each face lists a cascade of candidates per operating system and the browser activates the first one that exists:

ScriptmacOS · iOSWindowsAndroid · websize-adjust
ChinesePingFang SCMicrosoft YaHeiNoto Sans SC110%
Japanese (kana)Hiragino SansYu GothicNoto Sans JP115%
KoreanApple SD Gothic NeoMalgun GothicNoto Sans KR115%
ArabicGeeza ProSegoe UI · TahomaNoto Sans Arabic140%
HebrewArial HebrewSegoe UINoto Sans Hebrew125%
ThaiThonburiLeelawadee UINoto Sans Thai130%
DevanagariKohinoor DevanagariNirmala UINoto Sans Devanagari130%
BengaliBangla Sangam MNNirmala UINoto Sans Bengali125%
TamilTamil Sangam MNNirmala UINoto Sans Tamil125%

All of them are neutral system grotesques, chosen to rhyme with a sans-serif brand font. Nine unicode-range blocks cover everything from CJK to the Indic scripts.

3. Optical calibration per script, not a magic number

size-adjust multiplies the face’s metrics, the scaling of the em, without touching the computed font-size: components, rem units and spacing are left intact. It is the same normalisation mechanism for fallbacks as ascent-override or descent-override, applied to optical size.

The temptation is to set a single value for everything non-Latin. Typography disagrees: each writing system occupies a different proportion of the em square. Geeza Pro’s Arabic draws low, with timid ascenders, and needs 140% to stand at the height of the Latin. Han ideographs fill almost the whole em and sit level at 110%. A uniform value that rescued Arabic would leave Chinese out of proportion in the middle of a paragraph.

Every value in the table comes from a direct visual comparison against Latin, script by script, value by value:

size-adjust calibration scales for Arabic and Chinese, with values from 100% to 150% rendered next to Latin text. The chosen value is highlighted: 140% for Arabic and 110% for Chinese.

The CSS property is the same in both cases. What differs is that here each value was decided by looking at the result and written down so that someone else can repeat it. One implementation detail conditions the whole architecture: size-adjust is a @font-face descriptor, not a property, and it only exists inside a face whose src resolves. This per-script scaling is literally impossible by stacking families in font-family.

The details that separate “works on my machine” from “works”

The names each browser uses to resolve local()

This is where the technique breaks silently, and hardly anyone documents why. An installed font exposes several records in its OpenType name table: the family name (PingFang SC), the full name (PingFang SC Regular) and the PostScript name (PingFangSC-Regular, unique, no spaces, tied to a specific cut). The specification allows local() to resolve by full name or PostScript name; what each engine actually does I had to verify by testing it:

Browserlocal() resolves by…
Chrome · Edgefull name or PostScript only
Safari · Firefoxfamily name as well

local('Geeza Pro') works in Safari and fails in Chrome, precisely the majority browser. And the failure mode is poisonous: no resolution means no face, no face means no size-adjust, and the whole system degrades to the default fallback without a single console error. The only defence is listing all three forms of the name in every src:

src: local('PingFang SC Regular'),  /* Chrome/Edge: full name */
     local('PingFangSC-Regular'),   /* Chrome/Edge: PostScript */
     local('PingFang SC'),          /* Safari/Firefox: family */

The browser walks the list in order and skips names that do not resolve: adding variants carries no risk, it only widens compatibility.

One face per script, not one face with everything

A single @font-face holding all the local() entries and all the ranges looks more compact, but it hides a trap: a face activates one physical font, the first candidate that resolves, for the entirety of its unicode-range. If PingFang wins the race, Hangul and Arabic are orphaned and fall to an unscaled fallback. Each script needs its own face, with its own font and its own range.

A weight-agnostic family

The faces declare no font-weight and anchor to the system’s Regular cut. Non-Latin scripts do not offer the fine gradation of a Latin grotesque (asking for font-weight: 100 over CJK produces synthetic smearing) and a single family keeps everything in one file. The trade-off, stated openly: non-Latin bold is synthetic over the Regular. If a market asked for it, 700 faces would be added using the PostScript names of the Bold cuts. Until then, it is complexity that does not pay for itself.

The known limit, in writing

No technique covers every case, and documenting the boundary is part of the deliverable. Chrome on Android restricts local() over a good part of the system fonts as an anti-fingerprinting measure. In CJK the Notos usually resolve because they are families with an addressable name; Arabic is more fragile, because on some devices it lives as an anonymous fallback family in fonts.xml, out of reach of any alias. When that happens, the exits are :lang(ar) { font-size: 1.4em }, which does not depend on local(), or a surgical webfont for that script and that traffic segment alone, a decision that can by then be taken with market data in hand. Having the limit written down stops a team chasing a “bug” for days when it is in fact a platform restriction.

Taking it to another product

The technique rests on three standard CSS primitives (unicode-range, size-adjust and local()) and does not depend on my brand or my stack: moving it across takes changing the Latin font at the end of the token.

What it buys is easy to count. A launch in Arabic, Chinese or Thai does not wait for a licence or for a webfont pipeline to be built, because the typographic coverage is already in production. No new licences and no font hosting are paid for, and there are no CJK megabytes punishing LCP in the very markets being opened. Adoption was two tokens: no team touched a component or wrote per-language CSS. And the known limits are written down, which is the only way to stop anyone discovering them by losing an afternoon.

Arabic is the first script worth starting any test with: it is where the calibration shows most, and the one that reveals soonest whether local() is really resolving in Chrome.