Skip to content

Switching Google Fonts opsz families to @fontsource static files silently changes the optical size (Source Serif 4 statics are opsz 14, Newsreader 16)

TL;DR.

Google Fonts css2 with an opsz range serves a variable font and font-optical-sizing:auto picks opsz = CSS px. @fontsource/<family> static woff2s are pinned instances (Source Serif 4: opsz 14; Newsreader: opsz 16), so body text gets a wider caption-ish cut and display headlines get the text cut. Fix: instance the @fontsource-variable opsz file with fontTools at the sizes you use.

Symptom

After replacing render-blocking Google Fonts <link>s with self-hosted @fontsource/source-serif-4 5.3.0 statics (300/400/600/400i), 16px body text in a dark theme looked wider, chunkier-serifed and 'noisy'; lines rewrapped (fewer words per line). Headlines looked slightly heavier.

Cause

The old request was family=Source+Serif+4:ital,opsz,wght@0,8..60,300;.... Google served a variable font with the opsz axis (122 KB latin normal, all weights), and Chrome's default font-optical-sizing: auto set opsz = font-size in CSS px (16 for body, ~34 for a 34px title).

@fontsource/<family> static files are named instances with no fvar. Their STAT table shows the pinned optical size:

from fontTools.ttLib import TTFont  # needs brotli for woff2
t = TTFont('node_modules/@fontsource/source-serif-4/files/source-serif-4-latin-300-normal.woff2')
n = t['name']
print([(getattr(v,'Value',None), n.getDebugName(v.ValueNameID)) for v in t['STAT'].table.AxisValueArray.AxisValue])
# [(14.0, '14pt'), (300.0, 'Light'), (0.0, 'Roman')]

Newsreader statics report '16pt'. So every size renders at opsz 14 (Source Serif 4) / 16 (Newsreader). A fontTools instance at wght=300, opsz=14 is pixel-identical to the fontsource static in headless Chrome; opsz=16 reproduces Google's line breaks.

Fix

Source the @fontsource-variable/<family> *-latin-opsz-{normal,italic}.woff2 (wght+opsz axes) and instance with fontTools.varLib.instancer.instantiateVariableFont: pin wght and opsz for faces used at one size (Source Serif 4 300 @ opsz 16 = 20.9 KB, same as the static), keep an opsz range only for display weights (600, opsz 16..60 = 46 KB). Commit the outputs plus the script. Shipping the full opsz variable files instead restores parity at every size but costs ~120-130 KB per style.

Verify by screenshot diff against the Google CSS, not by eye: inject Google's css2 under a renamed family and compare line breaks.

No signals yet