Text visibly reflows when a webfont replaces the fallback. What do the CSS @font-face descriptors size-adjust, ascent-override and descent-override do about that, and what are you matching them against?
answer
- two fonts, two sets of metrics
- width changes wrapping, ascent changes line height
- declare a fallback face you can retune
- local() source means no download
- percentages derived from the font metric tables
basics
~20 sThey retune a fallback font's metrics to match the webfont's. size-adjust scales glyph outlines and advance widths so text occupies the same width; ascent-override and descent-override replace the metrics that set line-box height. Matched metrics mean the swap changes glyphs without moving anything.
solid answer
~50 sThe reflow happens because the fallback and the webfont have different metrics: different advance widths, so a paragraph wraps at different points, and different ascent/descent values, so each line box is a different height. Swapping fonts therefore changes the block's height and pushes everything below it. The fix is to declare a **second** `@font-face` whose `src` is `local("Arial")` — a font already on the machine, so no download — and give it `size-adjust`, `ascent-override`, `descent-override` and `line-gap-override` tuned so its rendered metrics match the webfont's. Then list it as the fallback: `font-family: "Fam", "Fam Fallback", sans-serif`. `size-adjust` is a percentage that scales outlines and advance widths, so you use it to match average character width and x-height; the override descriptors are percentages of font-size that replace the metrics used to compute line height. You derive the numbers from the two fonts' own metrics tables. The catch is that the named local font must actually exist on the device, so realistically you need a per-platform fallback stack.
code
css · 18 lines@font-face {
font-family: "Fam";
src: url(/fonts/fam.woff2) format("woff2");
font-display: swap;
}
@font-face {
font-family: "Fam Fallback";
src: local("Arial");
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
body {
font-family: "Fam", "Fam Fallback", sans-serif;
}go deeper
Know that different fonts take different amounts of space, so replacing one with another can move the text around, and that CSS has descriptors to bring a fallback closer to the webfont.
Explain the two independent causes — advance widths changing where lines wrap, and ascent/descent changing line-box height — and which descriptor addresses each. Be able to write the second @font-face with a local() source.
Show the whole pattern end to end: derive the percentages from both fonts' metric tables in the build, name the fallback family in the stack, and be honest that it matches averages rather than guaranteeing zero movement.
Treat it as infrastructure: percentages computed from the font files so they cannot drift, per-platform fallback faces maintained as a set, and a clear position on when to spend this effort versus accepting the shift or changing the loading strategy instead.
## Why the swap moves things When a webfont finishes loading and replaces whatever was rendering before, two independent things can change: 1. **Horizontal metrics.** Every glyph has an advance width. If the fallback's average advance is wider or narrower than the webfont's, the same string occupies a different width, so a paragraph may wrap into a different number of lines. A block that was five lines becoming four moves everything below it. 2. **Vertical metrics.** A font declares ascent, descent and line gap, and the browser uses them (scaled by `font-size`) to compute the line box height for `line-height: normal` and to position the baseline. Two fonts at the same `font-size` routinely produce visibly different line heights. Either one shifts content. Both together are why an unmatched swap is one of the most reliable ways to move a page after it looked settled. ## The descriptors These are `@font-face` **descriptors** — they belong inside the at-rule, not in a normal style rule: - **`size-adjust: <percentage>`** — scales the face's glyph outlines *and* its advance widths by that percentage. At `size-adjust: 105%`, glyphs render 5% larger for the same `font-size` and take 5% more horizontal space. This is the knob for matching apparent size (x-height) and, with it, line-break behaviour. - **`ascent-override`, `descent-override`, `line-gap-override`** — each a percentage of the used `font-size` that *replaces* the corresponding metric the font file declares. These control the line box, so they are the knob for matching line height. Note the distinction from the CSS property `font-size-adjust`, which is a different thing: it is a property applied at the point of use to preserve apparent x-height across a fallback chain. The descriptors above configure a face; the property adjusts usage. Interviewers do probe this confusion. ## The pattern You cannot put overrides on a system font you did not declare, so you declare one — pointing at a font that is already installed, via `local()`, which means no network request: ```css @font-face { font-family: "Fam"; src: url(/fonts/fam.woff2) format("woff2"); font-display: swap; } @font-face { font-family: "Fam Fallback"; src: local("Arial"); size-adjust: 107%; ascent-override: 90%; descent-override: 22%; line-gap-override: 0%; } body { font-family: "Fam", "Fam Fallback", sans-serif; } ``` While `fam.woff2` is in flight, text renders in Arial *as retuned* — the same apparent size, the same advances, the same line height as the real thing. When the webfont arrives it substitutes glyph-for-glyph in the same boxes. The letterforms change; nothing moves. ## Deriving the numbers They are not guesses. Both fonts publish their metrics — `unitsPerEm`, and ascent/descent/line-gap in the `hhea` and `OS/2` tables. The override percentages are the fallback's target metrics expressed as a fraction of the em: taking the webfont's ascent divided by its `unitsPerEm` gives the percentage to write for `ascent-override`. For `size-adjust`, you compare a width measure — x-height, or the average advance width across the characters you actually render — between the two fonts, and use the ratio. Doing it by hand once teaches the mechanism; in a build you compute it from the font files so it cannot drift when the family is updated. ## The caveats that matter in production - **The local font must exist.** `local("Arial")` resolves to nothing on a machine without Arial, and the browser moves on to the next family in the stack — which has none of your tuning. Practical setups declare per-platform fallbacks, because the sensible local target differs across operating systems, and the tuned percentages differ per target font too. - **It cannot be perfect.** You are matching an average. Individual words still set slightly differently, so a line can still break in a different place on a narrow column of long words. The technique removes the large systematic shift, not every pixel. - **It only helps when the fallback is genuinely rendered.** If your loading strategy means text is never painted in a fallback, there is nothing to match. - **Support.** `size-adjust` and the override descriptors are available in current Chromium, Firefox and Safari 17 and later; an older engine ignores the descriptors and uses the local font's own metrics, so the page degrades to the untuned behaviour rather than breaking. ## Why this is worth doing at all Of the ways to avoid a font-driven shift, this is the one that costs nothing at runtime: no extra bytes, no delayed text, no compromise on which typeface you ship. It is pure build-time arithmetic embedded in a stylesheet, and it is the reason a well-built site can swap in a webfont late without the page visibly jumping.
- Why is the fallback declared with src: local(...) rather than a url()?Because the point is to retune a font the device already has, so nothing is downloaded. local() resolves against installed fonts by name; the @font-face wrapper exists only to give you somewhere to hang the override descriptors, since you cannot apply them to a bare generic family named in the font stack.
- How is the @font-face descriptor size-adjust different from the CSS property font-size-adjust?The descriptor configures a face: it scales that face's outlines and advance widths by a percentage wherever it is used. The property is applied at the point of use and adjusts rendered size so that x-height stays consistent across whichever family in the stack ends up being used. Different scope, different mechanism, similar-sounding names.
- After tuning the overrides, can you say the swap causes no shift at all?No. You have matched averages — apparent size, average advance width, and the line-box metrics — so the systematic block-height change disappears. Individual line breaks can still land differently on narrow measures with long words, and the tuning only holds for the specific local font you targeted. It is a large reduction, not a guarantee.
- What happens on a device that does not have the local font you named?local() fails to resolve, that face contributes nothing, and matching falls through to the next family in the stack — an untuned system font, so the original shift returns. This is why production setups declare several tuned fallback faces, one per plausible platform default, each with percentages computed against that specific font.
saying these in an interview costs you the question
- Confuses the size-adjust descriptor with the font-size-adjust property
- Thinks the overrides change the webfont's own metrics
- Guesses the percentages instead of computing them from font metrics
- Assumes one local() fallback exists on every platform
- Claims metric matching eliminates every possible shift