All posts

All posts

Holding the page still when the brand typeface loads

Fitting fallback faces so text stops jumping when the brand typeface lands, and the three ways the measuring gate was blind to what visitors saw.

The public site sets its text in Spline Sans, served from its own origin. Until the face arrives, the browser paints the text in whatever system face it has, and when the brand face lands every line whose width changed rewraps and pushes the page down. On a slow phone the English landing page's hero moved by a quarter of the screen, and most French legal pages shifted by five times the layout-shift budget. Fixing it took four days, and most of that time went into making the measurement tell the truth.

Fitted fallback faces#

The fix itself is standard. The brand package declares a fallback @font-face per brand face, pointing at a local system face and adjusting its size and line box so it takes the same room as Spline Sans. Text still paints before the brand face arrives, which is the point of not blocking on it, and the swap no longer moves anything.

The fallback names Arial and its metric twins, which is what most desktop visitors have. That was enough for every landing, service and form page to measure under budget. Five long pages still rewrapped a line, and the gate that measures every page of the built site kept them pinned over budget.

The gate could not see the fix#

The gate loads each page of the production artifact cold, throttled to Lighthouse's mobile profile, and fails any page past its budgets for paint time, blocking time, layout shift, bytes and requests. It runs headless Chromium on Linux, in CI and locally, and neither has Arial. Every local() lookup in the fallback missed, the browser fell through to a face of other widths, and the gate measured a shift no visitor with Arial would see. The fix was a fontconfig file for the gate that adds Liberation Sans, which has Arial's metrics.

That was not enough, and the reason took longest to find. Headless Chromium hints glyphs fully by default, and fully hinted, it draws a local face at whole pixel sizes. The fallback's size adjustment is about 1%, which rounds away: the same line measured the same width with and without it. Four of the five pinned pages were not rewrapping for visitors at all. Turning hinting off in fontconfig changed nothing; only Chromium's own --font-render-hinting=none switch does. Launched with it, the gate agreed with Chrome on Windows, with real Arial, within 0.0001 on ten pages.

The third blind spot was in the measurement itself. The gate emulates a phone with touch, and under that emulation Chromium flags a page load's own layout shifts as following recent input. web.dev's definition of cumulative layout shift leaves those out, so a collector written to the definition read every page as perfectly still while they moved by 0.05 to 0.25. The gate never sends input, so it counts every shift.

Android has no Arial#

Android has none of the faces the fallback names. Its text painted in Roboto, at Roboto's widths, and the French data processing agreement moved by 0.14 in a browser offered Roboto alone. The brand package gained a second fallback family fitted to Roboto, which put that page and the others back under budget there. Monospace text needed nothing: Android resolves it to Droid Sans Mono, whose advance is 0.6 em, like Spline Sans Mono's.

Every common phone width#

The gate had loaded every page at 412 px only, Lighthouse's width. At 360, 390 and 430 px, which most phones use, French legal pages, the business card and the French home page rewrapped when the brand face landed, and nothing measured it. The gate now loads every page at all four widths.

Part of what it found was the fit itself. Every weight had been fitted to running prose, but weight 500 shared the regular face and ran about 1% narrow, and the bold faces ran about 2% narrow on the site's headings. The brand package now fits each weight to the text the site actually sets in it. One detail of that fit surprised me: Spline Sans's space is 0.18 em against Arial's 0.28 em, so fitting on sample labels joined with spaces skewed the result by about 1%. Each sample line is now shaped on its own.

Centred text turned out to be worse than left-aligned text. A left-aligned line that rewraps moves what is below it down; a centred one also jumps sideways, by up to half a line, and layout shift multiplies the moved area by the largest distance anything moved. On the French business card page, centring alone tripled the score. Holding the text to a width where it wraps the same in both faces took it from 0.0442 to 0.0043.

The floor no fit reaches#

Some rewraps cannot be fitted away. Even with a fallback exact over the whole text, letter for letter the two faces differ by several percent either way, which over a phone's line leaves a spread of about 1.5%. A line whose last word ends within that of the column edge breaks a word earlier or later when the brand face lands. Splitting the fallback into faces per group of letters brought the spread to about 1%, at the price of visibly unequal glyphs in the first paint, and rewording a sentence only moves its break to another width.

So the budget is set per width. At 412 px, where every baseline is taken, it stays at 0.015, which is one rewrap near the top of a page and nothing more. At the other three widths it is 0.25, web.dev's line for a poor page: it fails a page that moves wholesale and tolerates one heading taking an extra line, and the report records every width so a single rewrap stays visible.

A rewrap that comes from the layout rather than the text can still be removed. On the legal pages the version, the date and the download button sat in one row, and whether the French stamp fitted beside its button came down to a few pixels. At 360 px that moved all eight French documents by 0.05. On phones the three now stack.

What it cost#

Three releases of the brand package, the last two each adding bytes to a stylesheet with a hard budget: 61 bytes for the Roboto family and 648 for the two new fitted faces, both argued in the budget ledger before the limit moved. On the baseline taken on 2026-09-26, every page but the two about pages paints its largest element near 1.8 s, and at 412 px the worst layout shift left on any page is 0.013.