Why does letter-spacing break Chinese text?
Tracked-out monospace is a Latin idiom, and it breaks Chinese. Han characters are drawn into a fixed em square with almost no side bearing, so every positive tracking value you add to a label pushes the glyphs off the grid that makes a column of Chinese read as a column; the leading that makes English comfortable leaves Chinese cramped, and a font stack that depends on a webfont for its primary script puts megabytes on the critical path.
Every interface I ship carries Chinese, and it is never the secondary case. The Latin habits that arrive with a design system are the ones that cause damage, because they look neutral in review. A .eyebrow class with letter-spacing: 0.08em is a typographic convention in English. Applied to a four-character Chinese label, it is a layout bug.
The reason is structural. Tracking exists in Latin type because lowercase letters have irregular side bearings and uppercase letters are set too tight at small sizes; opening the gaps helps the eye find word shapes. Monospace exists so that code and digits align in columns. Both are compensations for variable or misleading advance widths. Han has neither problem: an ideographic character occupies one em of advance, and so does full-width punctuation. A line of Chinese is already a grid of squares. Tracking does not open that grid up — it takes it away, one character at a time.
Chinese text is already monospaced by design: every Han character occupies a full em square, so positive tracking does not open the text up, it removes the grid that makes it readable.
The arithmetic is small and adds up fast. At 0.05em on a fourteen-pixel label, each character moves 0.7px. A twelve-character Chinese label therefore gains 0.6em — about 8.4px — which is enough to wrap a label onto a second line inside a 120px sidebar, or to push a right-aligned value out of its column. Nothing errors. The layout simply stops fitting at one locale and not the other, and the bug report arrives as "the page looks wrong in Chinese".
What breaks first when Latin type rules meet Han characters?
The failures cluster, and they cluster in the same order every time I audit a bilingual codebase.
| Latin technique | What it does for English | What it does to Chinese |
|---|---|---|
| Positive tracking on labels (0.05em-0.12em) | Opens tight uppercase and small text | Slides each Han glyph off the em grid; line breaks get worse, right edges get ragged |
| Monospace family for IDs and timestamps | Aligns digits into columns | The monospace face has no Han glyphs, so Han arrives from a system fallback at 1em advance against 0.6em digits — alignment dies anyway |
text-transform: uppercase on eyebrows | Adds visual weight | No-op on Han, so a mixed label gets heavy Latin next to unchanged Han |
| Line-height 1.4-1.5 tuned on Latin body | Comfortable English blocks | Han ink fills far more of the em square, so the same multiple reads as cramped at small sizes |
text-align: justify | Even edges, spaced words | Han has no word spaces, so justification redistributes space between characters and opens rivers |
| Synthetic oblique | Emphasis in a pinch | The browser skews Han strokes, which has no precedent in Chinese typesetting and reads as a rendering fault |
A Latin webfont with a wide unicode-range | Consistent Latin metrics | It will not carry U+3001 or U+3002, so the browser picks CJK punctuation from an arbitrary fallback |
One more failure is subtler and worth separating out: Han unification. The same codepoint renders with Chinese or Japanese regional glyph shapes, and the only lever you have is the family order in the stack. A Chinese-first stack renders Japanese text with Chinese forms and no tool will flag it — the codepoints are identical, only the drawing differs.
Which font stack sets Chinese without a webfont?
The stack has to be built so that Latin comes from a face you control and Han comes from the operating system. Font matching is per glyph, so a family that lacks Han simply falls through; the trick is to make that fallthrough intentional rather than accidental, and to constrain the webfont so it can never claim characters it draws badly.
:root {
/* Latin first, then every plausible system Han face, in platform order. */
--font-sans: "Inter var", -apple-system, BlinkMacSystemFont, "Segoe UI",
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC",
"Source Han Sans SC", sans-serif;
}
/* Restrict the webfont to Latin, Latin Extended, general punctuation and
currency. Han and CJK punctuation are excluded on purpose, so they can only
ever come from the system stack. */
@font-face {
font-family: "Inter var";
src: url("/fonts/inter-var.woff2") format("woff2-variations");
font-weight: 100 900;
font-display: swap;
unicode-range: U+0000-024F, U+1E00-1EFF, U+2000-206F, U+20A0-20CF;
}
body {
font-family: var(--font-sans);
letter-spacing: normal;
font-synthesis: none; /* no synthetic oblique on Han, ever */
}
.eyebrow {
letter-spacing: 0.08em; /* Latin-only idiom, scoped to a class */
text-transform: uppercase;
}
/* Any Han run inside the page resets both, because neither is inherited safely. */
:lang(zh-Hans), :lang(zh-Hant) {
letter-spacing: 0;
line-height: 1.75;
text-transform: none;
font-feature-settings: "palt" 0; /* keep full-width forms; palt is a JP display idiom */
text-spacing-trim: trim-start; /* trim leading full-width punctuation */
text-autospace: normal; /* insert space at Latin-Han boundaries */
}
Two things about this are load-bearing. First, letter-spacing inherits, so a single label rule leaks into every nested Han run unless something resets it; the :lang() block is that reset, and I put it in the base stylesheet rather than in a component so it cannot be forgotten. Second, :lang() only works if the HTML carries the language. I have watched a Chinese page ship with lang="en" on the <html> element because the layout component hard-coded it, which silently disables the entire block. For inline runs, <span lang="zh-Hans"> is the contract.
That contract is the same one I describe in the gap between design intent and the CSS that actually ships: a rule that lives only in a design frame, or only in a component that one team owns, disappears the first time someone writes a new page. Scoping per-script type rules into the base layer is what makes them survive.
How much leading does Han text actually need?
More than Latin, and this is the part designers most often get wrong in the other direction. A Latin lowercase x-height sits at roughly half the em, so at a 1.5 line-height there is real air between lines. Han glyphs are drawn to fill their square — most designs use 80 to 95 percent of it — so the same multiple produces visibly denser text. Chinese typesetting practice sets body text at a 1.75 to 1.8 line-height, and that is what I ship.
The measure follows from the same em arithmetic, because a Han character advances exactly one em. At 16px with a 1.75 line-height, a 640px column holds 40 characters, which is the top of the comfortable Chinese range; anything wider needs an explicit cap. The recipe I keep in the codebase encodes this so a review does not have to.
| Script mix | line-height | letter-spacing | Characters per line | Primary font source |
|---|---|---|---|---|
| Latin only | 1.5 | 0 (negative only at display sizes) | 45-75 | Subset webfont is acceptable |
| Han only | 1.75 | 0 | 22-40 | System stack, no webfont for the primary script |
| Han with Latin runs (Han share 60% or more) | 1.75 | 0 | 30-50 | System stack for Han, webfont for Latin via unicode-range |
| Latin with occasional Han (Han share under 20%) | 1.6 | 0 on Han spans | 45-70 | Webfont for Latin, system fallback for Han spans |
export type Script = "latin" | "han" | "mixed";
export interface TypeRecipe {
/** Unitless line-height multiple, applied to the computed font size. */
readonly lineHeight: number;
/** Letter-spacing in em. Han never receives a positive value. */
readonly tracking: number;
/** Comfortable characters per line, inclusive. */
readonly measure: readonly [min: number, max: number];
}
/** Average advance in em for the dominant glyphs of each mix. */
const ADVANCE_EM: Record<Script, number> = { latin: 0.5, han: 1, mixed: 0.72 };
const RECIPES: Record<Script, TypeRecipe> = {
latin: { lineHeight: 1.5, tracking: 0, measure: [45, 75] },
han: { lineHeight: 1.75, tracking: 0, measure: [22, 40] },
// A mixed line takes Han leading and zero tracking: a cramped line reads as a
// defect before a generous one does, so the taller value wins and tracking loses.
mixed: { lineHeight: 1.75, tracking: 0, measure: [30, 50] },
};
export function recipeFor(
script: Script,
fontSizePx: number,
containerPx: number,
): TypeRecipe & { readonly charsPerLine: number } {
if (fontSizePx <= 0) throw new RangeError(`fontSizePx must be positive, got ${fontSizePx}`);
if (containerPx <= 0) throw new RangeError(`containerPx must be positive, got ${containerPx}`);
const charsPerLine = Math.floor(containerPx / (fontSizePx * ADVANCE_EM[script]));
return { ...RECIPES[script], charsPerLine };
}
The mixed row is what a bilingual product actually contains: Chinese sentences with product names, version numbers, SKUs and code identifiers inside them. Those lines want Han leading, and they want the Latin runs to keep their own font — which per-glyph fallthrough already gives you, as long as the webfont is not allowed to claim Han codepoints.
How do you keep a CJK webfont off the critical path?
You mostly do not ship one. Chinese coverage is expensive in a way Latin coverage is not: GB2312, the set that covers ordinary modern prose, is 6,763 Han characters — 3,755 frequent ones and 3,008 rare ones. Measuring the subsets I build, a Han glyph costs roughly 500 bytes in woff2. That is 6,763 multiplied by 500, about 3.4 MB per weight, before hinting, before a second weight, and before the fallback pages of a long-tail corpus. At a realistic 8 Mbit/s effective throughput on a Chinese mobile connection, 3.4 MB is roughly 3.4 seconds of download, and the first paint either waits or reflows when the font finally lands.
Hosted CJK families solve this by slicing: the served CSS declares many small unicode-range fonts per weight instead of one file, so a page only fetches the slices it uses. That keeps first paint reasonable but leaves the total bytes in the same megabyte range for a text-heavy page. When a subset really is the right call — a display headline, a wordmark, a fixed set of UI labels — subset from the corpus you actually ship, and gate the build on the projected size.
set -euo pipefail
CORPUS=corpus/zh-hans-strings.txt
GLYPH_BYTES=500 # observed average for a Han glyph in our woff2 subsets
# Count distinct Han characters in the corpus, not the corpus length.
glyphs=$(python3 - "$CORPUS" <<'PY'
import sys
text = open(sys.argv[1], encoding="utf-8").read()
print(len({c for c in text if "\u4e00" <= c <= "\u9fff"}))
PY
)
projected=$((glyphs * GLYPH_BYTES))
echo "${glyphs} Han glyphs, projected $((projected / 1000)) KB per weight"
# Above 900 KB per weight, fall back to the system stack instead of shipping.
if [ "$projected" -gt 900000 ]; then
echo "refusing a Han webfont over 900 KB per weight" >&2
exit 1
fi
pyftsubset fonts/SourceHanSansSC-Regular.otf \
--text-file="$CORPUS" \
--layout-features='kern,liga,vert,vrt2' \
--flavor=woff2 \
--output-file=public/fonts/han-subset.woff2
--flavor=woff2 needs brotli in the build image, and vert,vrt2 only matter if you also set vertical text. Note what the gate protects: a per-route subset that grows past 900 KB means the page has drifted into being a full-text page, at which point the system stack is faster and better looking on every platform I have tested.
How do you audit a mixed-script content set for these rules?
Before touching CSS, measure the content. The number that decides which recipe applies is the Han share of each string, and that is a query, not an opinion. Assuming UI strings live in a table with a locale column:
-- U&'\4E00' is a Unicode escape for the first CJK ideograph; the class spans
-- the unified ideograph block, which is what "Han share" means here.
WITH measured AS (
SELECT
s.locale,
s.key,
s.source_text,
length(s.source_text) AS char_count,
(SELECT count(*)
FROM regexp_matches(s.source_text, U&'[\4E00-\9FFF]', 'g')) AS han_count
FROM ui_string AS s
WHERE s.locale IN ('zh-Hans', 'zh-Hant')
)
SELECT
locale,
key,
char_count,
han_count,
round(han_count::numeric / nullif(char_count, 0), 2) AS han_ratio,
source_text ~ U&'[\4E00-\9FFF][A-Za-z0-9]' AS han_then_latin,
source_text ~ U&'[A-Za-z0-9][\4E00-\9FFF]' AS latin_then_han,
char_count > 40 AS over_measure
FROM measured
WHERE han_count > 0
ORDER BY over_measure DESC, latin_then_han DESC, han_count DESC
LIMIT 25;
han_ratio above 0.6 takes the Han recipe, between 0.2 and 0.6 takes mixed, and below 0.2 stays Latin with Han spans reset to zero tracking. The two adjacent-script booleans are the autospace candidates: strings where a Latin word or a digit sits directly against a Han character are the ones where text-autospace earns its keep, and the list is short enough that a human can check each one.
When is this the wrong approach?
The rules above are not universal, and I would push back on applying them everywhere.
If the product is English-only with one Chinese landing page, none of this is worth a pipeline. A :lang(zh-Hans) block, a tracking reset and a system stack cover it, and the subsetting gate is pure overhead.
Short display type is a genuine exception. A four-character wordmark at 40px can carry a small amount of positive tracking as a display decision, and a subset webfont for exactly those four glyphs costs about 2 KB. The grid argument bites at body sizes and in labels, where characters per line and column alignment actually matter — not on four characters that are drawn, not read.
If you do not control the markup, :lang() fails quietly. Embedded forms, third-party widgets and user-generated content often arrive with no language attribute or with the host page's language, and the selectors never match. There, a class contract on your own wrapper is the only reliable lever.
And text-spacing-trim and text-autospace are Chromium-only today. On Safari and Firefox, full-width punctuation keeps its full-width advance, so a line ending in a Chinese full stop is one 0.5em notch looser than in Chrome. I take that inconsistency rather than inserting thin spaces by hand into content, which breaks copy-paste, search matching and any diff a translator has to review.
What to change in your stylesheet this week
Open the stylesheet, search for letter-spacing, and every hit that is not zero needs to be scoped to a class or a Latin-only selector; a bare letter-spacing on a container is a latent bug in every Chinese page that will ever be built on top of it. Then set leading and measure on a :lang(zh-Hans), :lang(zh-Hant) block in the base layer, with line-height: 1.75 and an explicit max-width that lands Chinese prose between 22 and 40 characters. Finally, verify the fallback stack on a device with no webfonts available — block /fonts/ in the network panel and read the page again, because that is what the majority of your Chinese readers see on first load and often on every load after a cache eviction. The system stack is not the fallback; for Chinese it is the design, and everything you render with a webfont is the exception.
Keep reading
- Design Engineering: Closing the Figma-to-Production Gap Without a Handoff2026-03-047 minDesign Engineering
- Accessible Form Patterns Are Four Primitives, Not a Component Library2026-02-259 minDesign Engineering
- Automated Contrast Testing Has to Resolve Colour in a Browser, and Fail Closed When It Cannot2026-02-108 minDesign Engineering
- The Boolean Test: A Shipping Protocol for Side Projects2026-02-026 minSuper Individual
- Design Tokens as Typed Code: If the Build Does Not Consume It, It Is Documentation2026-02-287 minDesign Engineering