Fallback Stacks for CJK and Emoji Fonts
This guide is part of the Fallback Font Stack Design guide, itself under the Typography Fundamentals & System Architecture area.
A single web font almost never covers every script a real page renders. A Latin display face like Inter or Söhne ships glyphs for the Latin, Cyrillic, and maybe Greek blocks — nothing else. The instant a user's content, a username, a product title, or a stray emoji falls outside that coverage, the browser has to substitute a different font for those characters, mid-run, inside the same line of text. If you have not planned for that substitution, the result is either "tofu" (the ☐ replacement-character box for genuinely missing glyphs) or a visually jarring mix of type styles where Japanese kanji render in a heavy system Mincho face next to a light-weight Latin headline. Neither is acceptable for production typography, and both are avoidable with a deliberately layered font stack.
The fix is not a bigger font file. Shipping full CJK coverage in a single web font is off the table: an unhinted CJK typeface commonly weighs 8–16 MB uncompressed because Han script alone requires tens of thousands of glyphs, several orders of magnitude beyond the few hundred glyphs a Latin font needs. Instead you segment responsibility by Unicode range and let the browser's font-matching algorithm pick the right face per character, the same mechanism covered in Unicode-Range & Font Subset Loading for reducing payload, but applied here to correctness rather than size.
Prerequisites
Before building a multi-script stack you should already understand:
- How
unicode-rangescopes an@font-faceblock to a subset of code points, as covered in How unicode-range Reduces Font Payload by 60–90%. - How the browser resolves a
font-familylist left to right, falling through to the next entry only for characters the current entry cannot render — this is exactly the mechanism Accessible Fallback Font Stacks & CLS Prevention uses for Latin metric matching, extended here across scripts. - Basic familiarity with Unicode block ranges: Latin (
U+0000-024F), CJK Unified Ideographs (U+4E00-9FFF), Hiragana/Katakana (U+3040-30FF), Hangul Syllables (U+AC00-D7A3), and the Emoji ranges scattered acrossU+1F300-1FAFFplus a handful of legacy symbol blocks.
Implementation
The core technique is to declare several @font-face rules under the same font-family name, each scoped to a different unicode-range, then reference that one family name everywhere in your CSS. The browser treats same-named @font-face declarations as a single logical font with segmented coverage — it does not download a range's font file until a character in that range actually appears on the page, so an English-only visitor never fetches the CJK or emoji weight.
/* Layer 1: your primary Latin/Cyrillic web font */
@font-face {
font-family: "AppSans";
src: url("/fonts/inter-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC,
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD;
font-display: swap;
font-weight: 400 700;
}
/* Layer 2: CJK coverage, subset per script if possible */
@font-face {
font-family: "AppSans";
src: url("/fonts/noto-sans-jp-subset.woff2") format("woff2");
unicode-range: U+3000-303F, U+3040-30FF, U+31F0-31FF, U+3400-4DBF,
U+4E00-9FFF, U+FF00-FFEF;
font-display: swap;
font-weight: 400 700;
}
/* Layer 3: dedicated colour emoji font, never subsetted by weight */
@font-face {
font-family: "AppSans";
src: local("Noto Color Emoji"),
url("/fonts/noto-color-emoji-subset.woff2") format("woff2");
unicode-range: U+1F300-1F5FF, U+1F600-1F64F, U+1F680-1F6FF,
U+2600-26FF, U+2700-27BF, U+1FA70-1FAFF;
font-display: swap;
}
body {
font-family: "AppSans", system-ui, "Noto Sans CJK JP",
"Segoe UI Emoji", sans-serif;
}
Line-by-line: the three @font-face blocks share the family name AppSans but declare non-overlapping unicode-range values, so the browser builds one composite font description internally. When it lays out a run of text, it checks each code point against the ranges in declaration order and lazily fetches only the file that owns the range containing that character — a page with no CJK text never triggers the second src download at all, which is the same lazy-fetch behavior that makes basic unicode-range subsetting effective for payload. The final font-family list on body is the ordinary CSS fallback chain: if AppSans itself fails to load (network failure, blocked request), system-ui and Noto Sans CJK JP become the real fallback faces, and sans-serif is the terminal generic. Note the emoji block explicitly prefers local("Noto Color Emoji") first — most Android and Linux systems already ship it, so the local() lookup avoids a network fetch entirely on those platforms, while iOS and Windows silently fall through to their own system emoji font before ever reaching your web font's range.
Handling the CLS and Weight-Mismatch Edge Case
The naive version above has two real failure modes: the CJK fallback face rendering with a wildly different line-height than your Latin display face (causing layout shift when it swaps in), and body text falling back to a default system CJK font that doesn't match your declared weight. Guard against both explicitly rather than hoping the defaults line up, mirroring the metric-matching approach described in Font Metrics & Baseline Alignment for the Web.
/* Explicit multi-script fallback stack with metric-aware ordering */
:root {
--font-latin: "AppSans", system-ui, sans-serif;
--font-cjk: "AppSans", "Hiragino Sans", "Noto Sans CJK JP",
"Microsoft YaHei", sans-serif;
--font-emoji: "AppSans", "Apple Color Emoji", "Segoe UI Emoji",
"Noto Color Emoji", sans-serif;
}
html[lang^="ja"] body,
html[lang^="zh"] body,
html[lang^="ko"] body {
font-family: var(--font-cjk);
/* CJK glyphs sit visually smaller within their em-box;
bump line-height so mixed-script paragraphs don't clip */
line-height: 1.75;
}
html:not([lang^="ja"]):not([lang^="zh"]):not([lang^="ko"]) body {
font-family: var(--font-latin);
line-height: 1.5;
}
/* Isolate emoji so a missing colour-emoji font never falls back
to a monochrome CJK glyph for the same code point */
.emoji, [data-emoji] {
font-family: var(--font-emoji);
font-variant-emoji: emoji;
}
This variant switches the active fallback chain by the document's declared lang attribute rather than relying purely on unicode-range matching within one family, which matters when a CJK visitor's entire page is CJK text — in that case you want the CJK-appropriate system fonts (Hiragino Sans, Microsoft YaHei) ranked ahead of a generic sans-serif, and a taller line-height because CJK ideographs occupy a full em-square and read cramped at Latin-tuned leading. The .emoji class plus font-variant-emoji: emoji is a defensive pin: on some Linux configurations without a colour-emoji font installed, an emoji code point can otherwise resolve to a monochrome "Unicode symbol" glyph from a CJK or symbol font, which is legible but visually wrong for what's meant to be a colour pictograph.
Verification
Confirm the segmented stack is actually being used, not silently falling through to tofu or an unstyled system face:
- Open DevTools → Elements → Computed, select a paragraph containing mixed Latin and CJK text, and expand "Rendered Fonts" (Firefox's Font Inspector shows this natively; Chrome shows it under the Computed panel's font section). You should see two or more distinct font sources listed for the one element — confirming per-character substitution is active rather than the whole node falling back to one face.
- In the Network panel, filter by
fontand load a Latin-only page: the CJK and emojiwoff2files should show zero requests. Then load a page containing a CJK string and re-check — only then should the CJK subset request appear, proving the lazy per-range fetch is working rather than downloading every layer up front. - Use
document.fonts.check('16px "AppSans"', '漢字')in the console. It returnstrueonly once the relevant range's font file has finished loading and the browser has the glyph available, which is a fast programmatic substitute for eyeballing the render.
Common Pitfalls
- Declaring one
unicode-rangetoo wide. If your CJK@font-faceblock's range accidentally overlaps the Latin block (e.g. an unboundedU+0000-FFFFcopy-pasted from a template), the browser may pick the CJK file for basic Latin punctuation, forcing a multi-megabyte download for a page with no CJK content at all. Keep ranges disjoint and verify with a code-point diff tool before shipping. - Shipping emoji glyphs baked into your primary web font. Colour emoji require the
COLR/CPALorsbixtable format, which most Latin display web fonts do not include — if you don't add an explicit emoji layer, emoji render as tofu or a monochrome outline instead of colour pictographs, and users notice immediately in usernames or reactions. - Forgetting
local()for system emoji fonts. Every major OS already ships a colour emoji font (Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji on Android). Skippinglocal("Noto Color Emoji")means you re-download emoji glyphs the OS already has, adding an avoidable request and violating the reuse the CSS Font Loading pipeline is designed to allow. - Not matching line-height for CJK content. CJK glyphs are drawn to fill a full em-square with little internal padding, so a
line-height: 1.4tuned for Latin x-height reads cramped and can cause characters to visually touch between lines — bump it for CJK-tagged content rather than using one global value. - Testing only in a browser with the CJK font preinstalled. A developer's own machine (especially macOS or Windows with East Asian language packs installed) silently has high-quality CJK fallback fonts available via
local()lookups that a bare Linux CI container or a stripped-down Docker image does not. Test the actual fallback chain on a machine without those language packs to see what a first-time visitor without your web font truly sees.
Frequently Asked Questions
Do I need unicode-range at all if I just list separate font-family fallbacks?
You can rely purely on the browser's fallback chain (font-family: "AppSans", "Noto Sans CJK JP", sans-serif) without any unicode-range segmentation, but that only works per-element — the browser picks one font for the entire text node based on whichever font in the chain claims to support the first character it needs, so a paragraph mixing Latin and CJK in one <p> can end up entirely rendered in the CJK fallback even for the Latin portions. unicode-range segmentation under one family name gives you true per-character substitution within a single text node, which the plain fallback chain cannot.
Why does the emoji font not update to a newer style automatically?
Because local("Noto Color Emoji") pins to whatever version the OS ships, which lags behind the latest Unicode Emoji release by months to years depending on the platform's update cadence. If you need guaranteed access to the newest emoji (e.g. a just-released Unicode 16 glyph), you must self-host the subset as your src fallback rather than relying solely on local(), accepting the extra download for users on outdated systems.
How much does adding a full CJK @font-face layer cost if a page never uses CJK text?
Zero additional network cost, because of the lazy-fetch behavior described above — declaring the @font-face rule only registers the range with the browser's font matcher; the woff2 file itself is not requested until a character inside that unicode-range actually needs to be painted. The only overhead is the negligible CSS parse cost of the extra rule, so it's safe to declare CJK and emoji layers defensively even on pages you expect to be Latin-only.
Should I subset the CJK font by which characters my site actually uses? Yes wherever the character set is bounded (product names, a fixed set of UI strings) — subsetting a Han-script font to only the glyphs your site renders can cut it from many megabytes to a few hundred kilobytes. For genuinely open-ended user-generated CJK content, full-script subsetting isn't feasible, so a maintained third-party subset like Google's "Noto Sans CJK" split-by-script builds is the more realistic baseline.