Design Archive
中EN

Centering icons and Chinese text for real

Icons and text were both set to center vertically in auto layout, yet the Chinese text always sat a little below the icon. The cause was the font file's own vertical metrics.

L.07 / 14
CATEGORY
Study
YEAR
2026
STATUS
Note
TOOLS
fonttools / figma / chromium

entryWrite-up

I was building a design system for a desktop product, with colors, font sizes and spacing set up as design variables (tokens), and buttons, inputs and navigation items built as components. Body text used the open-source Noto Sans SC (a sibling of Source Han Sans), in Regular and Bold only. Most of these components put an icon and a label side by side, centered vertically with auto layout; search boxes, menu items, navigation links and buttons all had this structure.

While building the components, the icon and the text were both set to center, yet the text always sat a little lower than the icon; setting the same text in Microsoft YaHei and in Noto Sans SC, the latter sat clearly lower. Reading the rendered bounds of the two showed the same font size, line height and alignment; the only difference was where the glyph ink sat inside the text box, and the cause was in the font file itself. “Centered but not looking centered” is often a font metrics problem, not a layout problem; measure where the ink actually sits before deciding what to change.

A font file records how tall the font is within a line: the part above the baseline is called the ascender, the part below it the descender. Layout software splits the extra line spacing evenly above and below, so the middle of the line box sits at half of ascender minus descender. Noto Sans SC has an ascender of 1160 and a descender of 288, which is far from symmetric; counting each font size as 1000 units, the middle of the line box sits 56 units above the center of the upper and lower bounds the font sets for Chinese characters (the ideographic em box), so the font itself pushes the characters down by about 0.056 of the font size: at 14px that is about 0.7px low, at 32px close to 2px; in a 44px button, a one-pixel gap between the center lines of icon and text is visible. These values were once set from the highest and lowest points of the glyphs across all weights, so that line spacing would match across platforms and no glyph would be clipped; centering was never their purpose. The CJK font guidelines that a major open-source font platform adopted later make the ascender and descender symmetric around the ideographic em box, but Noto Sans SC was listed early and never followed.

In the original font, the middle of the line box sits 56 units above the center of the Chinese characters.

Three routes were ruled out first: the design tool’s own option for trimming the space above and below (Vertical trim) crops the text box to cap height, which breaks the line-height tokens, so it is not a general fix; moving the icon down 1px inside components only treats the symptom and adds a special “optical offset” value to the tokens, while I wanted both the icon and the text to keep their own heights; switching to another Chinese font did not work either: of the mainstream Chinese UI fonts compared, some had equally asymmetric metrics, some sat high on one platform and low on the other, some had licenses that forbid modification, and switching fonts would rewrap text across the whole product.

The decision was to keep the glyphs and change only the font’s ascender and descender. Glyphs, advance widths and spacing stay the same, and only the position of the characters in the line box moves; the open-source license allows modification and redistribution as long as the font is renamed. Concretely, the three sets of metrics in the font tables (the one macOS reads, the one Windows reads and the typographic one) were unified into a single set, so that the middle of the line box lines up with the center of the ideographic em box. In theory an ascender of 1104 and a descender of 344 is the most symmetric, with the same total height as the original. Where a fix can be made at the source, it is not compensated for in every component: change the font once and every place that uses it changes together, with no special values such as “optical offset” in the tokens.

After the font was changed and uploaded to the design tool, the text did not move at all; it took more than a dozen uploads and 8 rounds of debugging. The guesses in the first 6 rounds, in order: that the font was not installed locally, when in fact it had been uploaded as a team font, not installed on the machine; a few small fonts of a dozen KB made just for debugging (probe fonts) took effect, so the suspicion was that the 17MB full variable font had failed to process, but static weights still had no effect; a probe font the size of the full character set also took effect, so the suspicion was that a name starting with “Noto Sans SC” made it count as the original, but renaming changed nothing; then the guess was that the design tool deduplicated fonts by glyph content, so only one invisible glyph was changed, still with no effect; in round 5 the glyph of a rare character was changed and that character showed as a placeholder box, which meant the file was in use but its metrics seemed to be ignored, so the direction was half right; round 6 brought the turn: the font with Noto in its name and exaggerated metrics took effect, while the one with a clean name and normal metrics still did not, which put the cause in the metric values, not the name.

Round 7 switched to a ladder test: 7 small fonts with different metrics uploaded at once and their positions measured together. The result: the design tool and the browser both snap the baseline to a whole pixel, but by different rules; the measured positions of all 7 probe fonts matched rounding to the nearest pixel, while the round-down rule inferred earlier missed 4 of them.

Measured positions of the 7 probe fonts in the design tool, against two rounding hypotheses.

The earlier round-down rule had been inferred from the measured positions of the Noto Sans SC built into the design tool combined with the metrics of the version 2.004 file, and that wrong premise was the root cause: the built-in font is an earlier version with different metrics. The wrong baseline produced the wrong rounding rule, and the wrong rule picked the metrics 1103 / 300; as it happened, after rounding in the design tool, these values put every size on the same pixel as the built-in Noto. So “the position did not change” was read as “the metrics had no effect”, and that led to a string of guesses about names, file size and content deduplication. The baseline for any comparison has to be measured on the spot, not borrowed from elsewhere as a premise.

Two things made the correction possible: one, each round came with a judgment that “this time it should work”, and each time I looked in the design tool and recorded honestly “still the same, no change at all”, and without these observations a wrong hypothesis would have kept being treated as a conclusion; two, changing one variable at a time gave way to a single ladder test: with exaggerated metrics and several variants at once, the rounding rule was fitted in one go, much faster, and less easily misled by coincidence.

With the two measured rules, the metrics were searched again, and the choice was an ascender of 1126 and a descender of 345. The criteria, in order: the same baseline on both sides, the smallest maximum offset, the smallest average offset, and a total height as close to the original as possible. The two rules: the design tool rounds the whole value to the nearest pixel; the browser first rounds the ascender and the descender separately, then splits the remaining line spacing evenly and rounds down. The same metrics can land 1px apart on the two sides, so the search was for values that put both sides on the same pixel at all 12 font sizes in the spec.

After the change, across the 12 font size and line-height pairs in the spec, the largest offset fell from 1.84px to 0.56px, the direction changed from uniformly low to some up and some down, and both sides agree. The remaining error comes from the baseline only being able to sit on a whole pixel (whole-pixel snapping): at 13/21, for example, the ideal baseline is 15.44px and can only be 15 or 16, which no font can remove.

Offsets at the 12 size / line-height pairs in the spec, all measured.

The offsets in the design files had always been smaller than on the live site: the older built-in Noto in the design tool sits only slightly low, while the version 2.004 the browser loads on the live site sits lower, so the problem was hard to see in the design files and only obvious online. The front end therefore must load the adjusted web font, or the live site keeps the old offset.

Measured in Chromium: offsets of the original and the adjusted font at 12 sizes.

The font family of the 15 text styles in the design system was already bound to one variable, so the switch only changed that variable’s value, and every text style and every text layer bound to a style switched over; measured in the design tool, all 12 sizes landed as expected; glyphs and widths were unchanged, and no text rewrapped anywhere. For reuse, the calculation became a small tool (a metrics calculator): enter an ascender and a descender, and it shows the baseline and offset on both sides for each size, and can search a range for the best pair.

The metrics calculator: the final values agree on both sides at all 12 sizes.

processBefore & after

8 rounds of debugging

1
Adjusted and original sit in the same place · guess: not installed locally · wrong: it was uploaded as a team font
2
Small probes of a dozen KB work · guess: the 17MB variable font failed · static weights still no effect
3
A full-size probe also works · guess: the name made it count as the original · renamed, still no effect
4
Only one invisible glyph changed · guess: deduplication by glyph content · still no effect
5
A changed rare glyph shows as a placeholder box · file used, metrics ignored · half right
6
Noto in the name with exaggerated metrics works · the cause is the metric values, not the name · turning point
7
7 small fonts with different metrics uploaded at once · real rounding rule measured · root cause found
8
Metrics recomputed with the new rule · all 12 sizes land as expected

Rounding rules

text
design tool  baseline = round( lineHeight/2 + (asc − desc)/2 × size )
browser      baseline = floor( (lineHeight − round(asc×size) − round(desc×size)) / 2 ) + round(asc×size)

Metrics and offsets

Original Noto Sans SC
Ascender 1160 · descender 288
Symmetric in theory
Ascender 1104 · descender 344
Picked with the wrong rule
Ascender 1103 · descender 300
Final
Ascender 1126 · descender 345
Original · browser
Average 0.84px · max 1.84px · all low
Built-in Noto · design tool
Average 0.45px · max 0.88px · all low (two exceptions)
Adjusted · both sides
Average 0.27px · max 0.56px · some up, some down

resultTakeaways

Finding
Changing only the vertical metrics while leaving the glyphs untouched cut the largest offset across our 12 spec sizes from 1.84px to 0.56px, and got the design tool and the browser computing the exact same position for the first time.
Parameters
Ascender / descender 1160 / 288 → 1126 / 345 · three metric sets unified · rounding rule fitted with 7 probe fonts
Open
The live site only benefits once it loads the adjusted web font; the whole-pixel rounding residue at sizes like 13/21 cannot be removed by any font.

logChangelog

2026-10
First entry

Related experiments

note
L.06(STUDY)2026

One font size, one line height

typefigmavariables
note
L.10(STUDY)2026

Getting 124 light pillars to line up before they rise

motionfigmamcp+1
note
L.01(STUDY)2026

(1) Choosing a base model

chest-xraypytorchrad-dino+1
← LabL.07 / 14
1 / 1

100%