Web fonts shift layout because the fallback font and the web font take up different amounts of space, so text rewraps when one replaces the other. There are two fixes. Make the fallback occupy the same space, using size-adjust, ascent-override, descent-override and line-gap-override on a local fallback face. Or avoid the late swap, with font-display: optional and fonts that arrive early: preloaded, self-hosted, WOFF2 and subset.
Why a font swap moves the layout
You open a page, start reading, and the paragraph jumps. A line that ended with one word now ends with another, and the button you were about to tap has moved down. Nothing was inserted. The text simply changed font.
Every font has its own proportions. At the same font-size, one family sets a sentence wider than another, and its built-in ascent, descent and line gap give a different default line height. While a web font downloads, the browser lays the text out with a fallback from your font stack. When the web font arrives, the browser lays everything out again with the new measurements.
- Width changes. Words take more or less room, so lines break in different places and a paragraph gains or loses a line.
- Height changes. With
line-height: normal, the line box comes from the font’s own vertical metrics, so every line can get taller or shorter. - Everything below moves. The text block changes height, and the content under it is pushed down or pulled up.
That movement is what Cumulative Layout Shift measures. web.dev makes a point that surprises people: hiding the text does not help. Even invisible text is laid out with the fallback font, so the surrounding content still shifts when the web font loads. The choice of font-display value decides when and whether a swap happens, which is covered in font-display explained. This guide is about making the swap harmless, or making it unnecessary.
Measure it before you fix it
First confirm that fonts are the cause. Images without dimensions, ads and injected banners produce the same symptom.
- Run Lighthouse. It reports Cumulative Layout Shift for the page load and lists the elements that moved. If the elements are text blocks and whatever sits below them, fonts are a likely cause.
- Record a load in the DevTools Performance panel. web.dev describes a Layout Shifts track in the recording, where each shift is marked and can be clicked to see which elements moved and by how much. Check whether a shift lines up with a font file finishing in the network section of the same recording.
- Make the swap visible. In the Network panel, disable the cache, pick a slow throttling preset and reload. A slow connection stretches the gap between fallback and web font, so you can see exactly what moves.
Match the fallback to the web font
If the fallback occupies exactly the same space as the web font, the swap changes the letter shapes and nothing else. Four @font-face descriptors make that possible. You do not apply them to the web font. You define a new family that points at a local system font with local(), and adjust that.
| Descriptor | What it does |
|---|---|
size-adjust | Scales the glyphs and all metrics of the face by a percentage. This is what matches the width. |
ascent-override | Sets the height above the baseline that CSS uses to lay out line boxes. |
descent-override | Sets the depth below the baseline used for line boxes. |
line-gap-override | Sets the extra gap the font recommends between lines. |
Here is a complete example for Inter with Arial as the fallback. These are the values the Chrome for Developers article on framework font tools shows for this pair.
@font-face {
font-family: "Inter";
src: url("/fonts/inter-variable.woff2") format("woff2");
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Inter Fallback";
src: local("Arial");
size-adjust: 107.4%;
ascent-override: 90.2%;
descent-override: 22.48%;
line-gap-override: 0%;
}
body {
font-family: "Inter", "Inter Fallback", sans-serif;
}The numbers come from the font files, not from trial and error. Chrome for Developers gives the formulas: size-adjust is the average character width of the web font divided by that of the fallback font. Each override is the web font’s metric divided by its units per em, and then divided by size-adjust. That last division is needed because size-adjust scales everything in the face, including the overrides.
Three practical notes. The match is per fallback font, so a stack that may land on Arial on one platform and Roboto on another needs one adjusted face for each. The same article recommends Arial as the fallback for sans-serif fonts and Times New Roman for serifs, and notes that Android has neither, only Roboto. And browser support is uneven: MDN lists size-adjust as widely available, but marks the override descriptors as limited availability, and names Safari as a browser where ascent-override does not work. In a browser without them, the width still matches and the line height may not. Setting an explicit unitless line-height on your text reduces how much the font’s own metrics matter. See line height and letter spacing.
Tools that compute the overrides for you
Reading metrics out of font files by hand is tedious, and several tools do it automatically.
- next/font. Built into Next.js. It self-hosts the font files and generates an adjusted fallback face for you. The
adjustFontFallbackoption is on by default for Google fonts, and for local fonts it defaults to Arial, with Times New Roman as the other choice. - Fontaine. A library from the UnJS project that scans your CSS and generates fallback
@font-facerules with metric overrides at build time. Its documentation lists integrations for Vite, Next.js, Docusaurus, Gatsby and Astro, and there is a Nuxt module built on it. - Capsize. Its
createFontStackfunction produces metric-based@font-facedeclarations for a list of fallbacks, and the companion@capsizecss/metricspackage ships ready metrics for system fonts and Google Fonts.
If you write @font-face rules yourself, the @font-face generator produces the web font rule, and you can add the fallback face from one of these tools next to it.
Avoid the late swap: font-display: optional and preload
The other strategy is to never swap after the first paint. font-display: optional gives the font a very short window to be ready. If it misses, the page keeps the fallback for the whole visit. web.dev describes the result as text that is delayed by no more than about 100 milliseconds, with an assurance that there are no layout shifts from font swapping.
The trade-off is honest: some first-time visitors on slow connections will not see your web font on that page view. The file can still be cached, so the next page usually has it. To make the font win its deadline more often, tell the browser about it early with a preload.
<link rel="preload" href="/fonts/inter-variable.woff2"
as="font" type="font/woff2" crossorigin>
<style>
@font-face {
font-family: "Inter";
src: url("/fonts/inter-variable.woff2") format("woff2");
font-weight: 100 900;
font-display: optional;
}
</style>optional. The crossorigin attribute is required on font preloads, even for files on your own domain.Use preload sparingly. web.dev cautions that it pulls browser resources away from other critical requests. Preload the one or two files that style text at the top of the page and nothing else.
Make the fonts arrive sooner
Whichever strategy you choose, a font that arrives sooner causes less trouble. A swap that happens before anyone has started reading is barely noticed.
Self-host, and use WOFF2
A font served from your own origin needs no extra connection to a third-party host, and you control caching and preloading. web.dev is careful to say that self-hosting is not automatically faster: it pays off when your own delivery is good and when you apply the optimizations a font service would apply for you. The first of those is the format. WOFF2 has the best compression and broad browser support, and web.dev quotes the advice to use only WOFF2 and drop older formats. The steps are in self-hosting Google Fonts.
Subset with unicode-range
Most fonts contain many characters your pages never use. Subsetting splits a family into smaller files by script, and the unicode-range descriptor tells the browser which characters each file covers. According to MDN, the browser downloads a file only if the page uses at least one character in its range.
@font-face {
font-family: "Literata";
src: url("/fonts/literata-latin.woff2") format("woff2");
font-weight: 400;
font-display: swap;
unicode-range: U+0000-00FF;
}
@font-face {
font-family: "Literata";
src: url("/fonts/literata-cyrillic.woff2") format("woff2");
font-weight: 400;
font-display: swap;
unicode-range: U+0400-04FF;
}Use a variable font to cut requests
Four static weights are four downloads and four possible swaps, each at its own moment. A variable font covers the whole weight range in one file, so there is one request and one swap. web.dev notes that a variable file is typically larger than a single static file, so it pays off when a site uses several weights or styles.
Verify which font is actually showing
Every technique here depends on knowing which font is on screen at a given moment. With optional, the fallback may stay for a whole visit by design. With a well matched fallback, you may not be able to tell by eye.
- In Chrome or Edge, right-click the text, choose Inspect and open the Computed tab.
- Scroll to Rendered Fonts at the bottom. It names the font actually used and says whether it is a network resource or a local file.
- A local file where you expected your web font means the fallback is showing. With the setup above, that is Arial drawn through your adjusted fallback face.
- Reload with throttling on and check again after the font file finishes in the Network panel. The entry should change to a network resource.
Font Inspector shortens this to one click. Pick any text and the panel shows the declared family next to the font actually rendered, with a fallback flag when they differ. Fonts on this page carries the same warning for every family at once, so after a deploy you can confirm that your web fonts load, and see straight away when a visit is being served by the fallback. For more ways to look at a page’s fonts, see how to see all fonts on a page.
Catch the fallback in the act.
Font Inspector shows the font actually rendered for any text and flags it when it is not the family the CSS asked for. Use it to confirm which face is on screen before and after the swap. Free, no account.
Questions & answers
Why do web fonts cause layout shift?+
The browser first lays out text with a fallback font and then again with the web font when it loads. The two fonts have different widths and vertical metrics, so lines rewrap, the text block changes height, and the content below it moves.
What does size-adjust do in @font-face?+
It scales the glyphs and metrics of a font face by a percentage. Applied to a local fallback font, it makes the fallback take up the same width as the web font, so text does not rewrap when the fonts swap.
Does font-display: swap cause CLS?+
It can. swap replaces the fallback whenever the web font arrives, and if the two fonts differ in size the layout moves. Pair swap with a metric-matched fallback face, or use font-display: optional to avoid late swaps entirely.
Does preloading fonts fix layout shift?+
It helps but does not guarantee it. Preloading makes the font arrive sooner, so the swap happens earlier or the font meets the optional deadline. On a slow connection the swap can still happen, so combine preload with a matched fallback.
Does next/font prevent layout shift automatically?+
Largely, yes. next/font self-hosts the files and generates a fallback font face with adjusted metrics by default, controlled by the adjustFontFallback option.
Related free tools: @font-face generator · CSS font stack builder
See it on real sites: What font does Figma use? · What font does Canva use? · What font does Shopify use? · What font does Slack use?
Sources and further reading: Chrome for Developers: Improved font fallbacks ↗ · Chrome for Developers: Framework tools for font fallbacks ↗ · web.dev: Optimize Cumulative Layout Shift ↗ · web.dev: Best practices for fonts ↗ · MDN: size-adjust ↗ · MDN: ascent-override ↗ · MDN: unicode-range ↗ · Next.js: Font module (next/font) ↗ · Fontaine on GitHub ↗ · Capsize on GitHub ↗