
How to Use size-adjust and Font Metric Overrides to Prevent CLS?
- Sajjad
- Typography
- 01 Oct, 2026
You have already done the obvious things. Your fonts are self-hosted WOFF2, the body weight is preloaded, and font-display: swap keeps text visible. Yet the article page still jumps when the web font arrives: the intro paragraph wraps onto one extra line, the byline slides down, and CLS sits at 0.08 on a good day and 0.15 on a bad one. The swap is unavoidable, but the movement is not. The fallback font is simply a different size and shape from the font that replaces it, and CSS now gives you descriptors to fix exactly that.
This article explains what size-adjust, ascent-override, descent-override, and line-gap-override do, how to calculate correct values from real font files, how to wire them into your stylesheet, and which tools automate the math so you never hand-tune a percentage again.
Why Font Swaps Shift Layout
When a web font is still downloading, the browser renders text in the next font in your font-family list, usually a system font such as Arial, Helvetica, or Times New Roman. When the web font arrives, the text is re-rendered. If the two fonts take up different amounts of space, the layout moves. (For the broader picture of how this feeds into your scores, see how web fonts affect Core Web Vitals and layout shift.)
Two kinds of difference cause the movement:
- Horizontal: the average glyph width differs, so lines break at different words and paragraphs gain or lose lines.
- Vertical: the font's ascent, descent, and line gap differ, so each line box has a different height when
line-heightisnormal, and the baseline sits in a different place.
The four @font-face descriptors covered here let you reshape the fallback so both differences shrink to almost nothing.
What the Four Descriptors Do
All four live inside an @font-face rule, and they modify the font that rule loads, not the element using it. That is the key idea: you create a new, adjusted version of a local system font and use it as your fallback.
| Descriptor | What it changes | Typical value |
|---|---|---|
size-adjust | Scales glyph outlines and metrics by a percentage | 90%–115% |
ascent-override | Sets the height above the baseline used for line layout | 80%–100% |
descent-override | Sets the depth below the baseline used for line layout | 20%–30% |
line-gap-override | Sets the extra leading the font adds between lines | Usually 0% |
size-adjust works like changing the font size for that face only, without touching your font-size declarations. A value of 110% makes 16px Arial render as wide as 17.6px Arial would, which lets it match a wider web font's average character width.
The three override descriptors replace the vertical metrics stored in the font's hhea and OS/2 tables. Percentages are relative to the font size, so ascent-override: 90% means the ascent is 0.9 em.
A minimal example looks like this:
@font-face {
font-family: "Brand Sans";
src: url("/fonts/brand-sans.woff2") format("woff2");
font-weight: 100 900;
font-display: swap;
}
@font-face {
font-family: "Brand Sans Fallback";
src: local("Arial");
size-adjust: 117.8%;
ascent-override: 80.6%;
descent-override: 21.2%;
line-gap-override: 0%;
}
body {
font-family: "Brand Sans", "Brand Sans Fallback", sans-serif;
}
The browser tries Brand Sans first. While it loads, text renders in "Brand Sans Fallback", which is Arial reshaped to Brand Sans's proportions. When Brand Sans arrives, the swap changes letter shapes but not line breaks or line heights.
Browser Support
| Descriptor | Chrome / Edge | Firefox | Safari |
|---|---|---|---|
size-adjust | 92+ | 92+ | 17+ |
ascent-override, descent-override, line-gap-override | 87+ | 89+ | Not supported at the time of writing |
Unsupported descriptors are ignored, so there is no downside to including all four. Safari users still get the horizontal correction from size-adjust, which fixes line wrapping, the biggest source of shift. Setting an explicit unitless line-height on text elements reduces the vertical difference that Safari cannot override, because line box height then depends on the font size rather than on the font's internal metrics.
Reading the Metrics from Your Font Files
To compute correct values you need five numbers from each font: unitsPerEm, ascent, descent, line gap, and an average character width. The quickest way to get them is with fonttools in Python:
pip install fonttools brotli
# metrics.py
import sys
from fontTools.ttLib import TTFont
font = TTFont(sys.argv[1])
head, hhea, os2 = font["head"], font["hhea"], font["OS/2"]
print("unitsPerEm: ", head.unitsPerEm)
print("hhea ascent: ", hhea.ascent)
print("hhea descent:", hhea.descent)
print("hhea lineGap:", hhea.lineGap)
print("xAvgCharWidth:", os2.xAvgCharWidth)
python metrics.py fonts/brand-sans.woff2
python metrics.py /System/Library/Fonts/Supplemental/Arial.ttf
The brotli package lets fontTools open WOFF2 files directly. Arial's values are well known: unitsPerEm 2048, ascent 1854, descent -434, line gap 67, and xAvgCharWidth 904.
A note on average width: xAvgCharWidth in the OS/2 table is a crude average over all glyphs. Tools such as Capsize compute a better value by weighting each character by how often it appears in English text. For a quick manual calculation xAvgCharWidth is fine; for production, use a tool that weights by frequency.
Calculating the Values
Assume your web font, Brand Sans, reports these metrics:
| Metric | Brand Sans | Arial |
|---|---|---|
| unitsPerEm | 1000 | 2048 |
| ascent | 950 | 1854 |
| descent | -250 | -434 |
| line gap | 0 | 67 |
| avg width | 520 | 904 |
Step 1: size-adjust
Normalize both average widths to 1 em, then divide:
- Brand Sans: 520 / 1000 = 0.520 em
- Arial: 904 / 2048 = 0.4414 em
size-adjust= 0.520 / 0.4414 = 1.178 → 117.8%
Arial is narrower, so it needs to be scaled up by about 18% to match.
Step 2: the override descriptors
The overrides describe the web font's vertical metrics, but because size-adjust also scales the fallback's metrics, you divide by the size-adjust factor:
ascent-override= (950 / 1000) / 1.178 = 0.806 → 80.6%descent-override= (250 / 1000) / 1.178 = 0.212 → 21.2%line-gap-override= (0 / 1000) / 1.178 = 0%
Use the absolute value of the descent. These are the numbers in the CSS example above.
A small script for repeat use
If you prefer not to do it by hand each time, a few lines of JavaScript will do it:
function fallbackMetrics(web, fallback) {
const webWidth = web.avgWidth / web.unitsPerEm;
const fbWidth = fallback.avgWidth / fallback.unitsPerEm;
const sizeAdjust = webWidth / fbWidth;
const pct = (n) => (n * 100).toFixed(2) + "%";
return {
sizeAdjust: pct(sizeAdjust),
ascentOverride: pct(web.ascent / web.unitsPerEm / sizeAdjust),
descentOverride: pct(Math.abs(web.descent) / web.unitsPerEm / sizeAdjust),
lineGapOverride: pct(web.lineGap / web.unitsPerEm / sizeAdjust),
};
}
console.log(
fallbackMetrics(
{ unitsPerEm: 1000, ascent: 950, descent: -250, lineGap: 0, avgWidth: 520 },
{ unitsPerEm: 2048, avgWidth: 904 }
)
);
Covering Every Platform's Fallback
local("Arial") only works where Arial is installed. Windows and macOS ship it; many Android devices and Linux desktops do not. You can list several local fonts in one src, and the browser uses the first one it finds:
@font-face {
font-family: "Brand Sans Fallback";
src: local("Arial"), local("Helvetica"), local("Roboto"), local("Liberation Sans");
size-adjust: 117.8%;
ascent-override: 80.6%;
descent-override: 21.2%;
line-gap-override: 0%;
}
The catch is that the percentages were calculated against Arial. Helvetica and Liberation Sans are metric-compatible with Arial, so they work well. Roboto is not, so on Android you get a partial correction. For the most precise result, declare one fallback face per platform font, each with its own calculated values, and list them all in your stack:
@font-face {
font-family: "Brand Sans Fallback Arial";
src: local("Arial");
size-adjust: 117.8%;
ascent-override: 80.6%;
descent-override: 21.2%;
line-gap-override: 0%;
}
@font-face {
font-family: "Brand Sans Fallback Roboto";
src: local("Roboto");
size-adjust: 112.4%;
ascent-override: 84.5%;
descent-override: 22.2%;
line-gap-override: 0%;
}
body {
font-family: "Brand Sans", "Brand Sans Fallback Arial", "Brand Sans Fallback Roboto", sans-serif;
}
The Roboto values here are illustrative; run the calculation against Roboto's own metrics. Font matching happens per family in order, so a device without Arial skips straight to the Roboto-based face.
For a serif web font, calculate against Times New Roman (or Georgia), not Arial. The closer the fallback's design is to the web font, the less visible the swap is beyond layout.
Handling Bold, Italic, and Headings
A fallback face calculated from the regular weight is a good approximation for other weights, but bold text is wider. When the browser needs bold text from "Brand Sans Fallback", it uses Arial Bold (or synthesizes bold) with the same size-adjust. Arial Bold and Brand Sans Bold may not scale in the same ratio.
If headings in a bold weight are your LCP element or wrap differently after the swap, add a dedicated bold fallback face:
@font-face {
font-family: "Brand Sans Fallback";
src: local("Arial Bold"), local("Arial-BoldMT");
font-weight: 700;
size-adjust: 113.1%;
ascent-override: 84%;
descent-override: 22.1%;
line-gap-override: 0%;
}
Because it shares the family name and declares font-weight: 700, the browser picks it automatically for bold text. Calculate the values from the bold files of both fonts.
Letting Tools Do the Math
Hand calculation is good for understanding; in production, automate it.
next/font
Next.js computes a metric-matched fallback for every font loaded through next/font/google and next/font/local. The adjustFontFallback option is true by default for Google fonts and "Arial" by default for local fonts; you can set it to "Times New Roman" for serif local fonts. The generated CSS contains a fallback @font-face with size-adjust and all three overrides. Setup details are in how to use next/font in Next.js.
import localFont from "next/font/local";
export const brand = localFont({
src: "./fonts/brand-sans.woff2",
display: "swap",
adjustFontFallback: "Arial",
variable: "--font-brand",
});
Capsize
Capsize publishes precomputed metrics for Google Fonts and system fonts and can generate the fallback @font-face for you:
npm install @capsizecss/core @capsizecss/metrics
import { createFontStack } from "@capsizecss/core";
import arial from "@capsizecss/metrics/arial";
import inter from "@capsizecss/metrics/inter";
const { fontFamily, fontFaces } = createFontStack([inter, arial]);
console.log(fontFamily); // a font-family value: Inter followed by the generated fallback
console.log(fontFaces); // @font-face rules with size-adjust and overrides
For custom font files, @capsizecss/unpack reads the metrics directly from a file or URL.
Fontaine
Fontaine is a Vite, webpack, and Nuxt plugin that scans your @font-face rules at build time and injects fallback faces automatically. It suits projects where you do not want to manage the fallback CSS at all.
Verifying the Result
Never trust the numbers until you have looked at the swap.
- Visual overlay. Temporarily render the same paragraph twice, one in the web font and one in the fallback family only, positioned on top of each other with one at 50% opacity. Line breaks and baselines should line up closely.
- Block the font. In Chrome DevTools, open the Network panel, right-click the font request, and choose Block request URL. Reload, and you see the page in the adjusted fallback. Unblock and compare.
- Measure CLS. Throttle to Slow 4G, record in the Performance panel, and confirm the layout shifts that coincided with font loading are gone or tiny.
/* Temporary debug helper */
.debug-fallback {
font-family: "Brand Sans Fallback", sans-serif !important;
}
You will not get a perfect match, because glyph widths vary character by character. The aim is identical line breaks for typical paragraphs at your common widths, which removes nearly all font-related CLS.
Common Mistakes
- Applying the descriptors to the web font face. The overrides belong on the fallback face. Adding
size-adjustto your real font changes how your final typography looks. - Forgetting to divide by size-adjust. Overrides that ignore the scale factor make line boxes too tall.
- Calculating against the wrong fallback. Values computed for Arial applied to a serif stack produce poor matches.
- Leaving
line-height: normal. Explicit unitless line heights make vertical layout predictable everywhere, including Safari. - Not testing at the widths that matter. Check mobile and desktop container widths, since line wrapping is width-dependent.
Font Metric Overrides FAQ
Only if you put it on the web font's own font-face rule. When you put it on a separate fallback face that points at a local font, it only resizes that fallback, and your final typography is unaffected.
You still need a font-display value that shows text early, such as swap or optional. Metric overrides do not change when the font appears; they make the moment of swapping visually stable so it no longer counts as a layout shift.
Next.js and Capsize compute average width by weighting characters by their frequency in typical text, while xAvgCharWidth averages all glyphs equally. The weighted method usually gives a better match, so small differences are expected and the tool's values are generally the better choice.
Safari 17 and later support size-adjust, which corrects horizontal width and line wrapping. At the time of writing Safari ignores ascent-override, descent-override, and line-gap-override, so set explicit unitless line heights to minimize vertical differences there.
Yes. The overrides go on your own fallback face that uses a local system font, so it does not matter where the web font is hosted. You just need the web font's metrics, which tools like Capsize already publish for Google Fonts.
It eliminates most of it. Individual characters still differ in width, so occasionally a line breaks differently after the swap. In practice a well-matched fallback reduces font-related shifts to values that are negligible against the 0.1 threshold.
Conclusion
Font swaps cause layout shift because fallback fonts and web fonts occupy different amounts of space. The size-adjust descriptor corrects the horizontal difference so lines wrap in the same places, and the three override descriptors correct the vertical metrics so line boxes and baselines stay put. Together they turn a jarring reflow into a barely noticeable change of letterforms.
The math is simple once you have the metrics, but you rarely need to do it by hand. Use next/font in Next.js, Capsize or Fontaine elsewhere, and spend your time verifying the result by blocking the font in DevTools and checking that line breaks match. Pair the technique with preloading and a sensible font-display value, and fonts stop being a source of CLS. If you want to remove the swap entirely, a system font stack is the only approach with zero font loading.
Here are some useful references for going deeper on font metric overrides:
- Chrome for Developers: Improved font fallbacks — the Chrome team's explanation of size-adjust and override descriptors with worked examples.
- MDN Web Docs: size-adjust — syntax and behavior of the descriptor.
- MDN Web Docs: ascent-override — how the override descriptors replace font metrics.
- Capsize: Capsize on GitHub — metrics packages and the createFontStack API for generating fallback faces.
- W3C: CSS Fonts Module Level 5 — the specification that defines size-adjust and the metric override descriptors.


