
How to prevent layout shift caused by web fonts?
- Sajjad
- Typography
- 11 Oct, 2026
To prevent layout shift caused by web fonts, make the fallback font take up the same space as the web font, so nothing moves when one replaces the other. You do this by creating a fallback @font-face rule that points to a local system font and adjusts it with the size-adjust, ascent-override, descent-override and line-gap-override descriptors. Then you reduce how often and how late swaps happen: preload the main font, keep files small, and consider font-display: optional, which never swaps after the first render. Frameworks and tools such as Next.js's next/font, Fontaine and Capsize can calculate the override values for you. Together these steps remove most or all font-related Cumulative Layout Shift.
Layout shift from fonts is easy to miss on a fast developer machine but obvious to visitors on mobile networks: they start reading, the font swaps, and the paragraph jumps. It also counts against your Core Web Vitals. In this article you'll learn exactly why font swaps move content, how to see and measure the shift, how fallback metric overrides work and how to calculate them, which tools automate the process, and how to combine them with loading strategies for a stable page.
Why Font Swaps Move Content
When a web font hasn't loaded yet and font-display allows it, the browser renders text with a fallback such as Arial or Georgia. When the web font arrives, the text is re-rendered. If the two fonts have different proportions, the text occupies a different amount of space:
- Different widths: Wider or narrower letters change where lines break. A paragraph can gain or lose a line, pushing everything below it up or down.
- Different vertical metrics: The ascent, descent and line gap stored in each font determine the height of a line box when you use
line-height: normal, and affect where text sits within a line box. A font with taller metrics makes every line taller. - Different x-heights: A font with a large x-height looks bigger at the same
font-size, so it often runs wider too.
Every element whose position changes as a result counts as a layout shift. On a long article, a single swap can move dozens of elements.
Seeing and Measuring the Shift
Visualise Shifts in DevTools
- Open DevTools, open the Command Menu with Ctrl+Shift+P or Cmd+Shift+P, and run Show Rendering.
- Tick Layout Shift Regions. Areas that shift are highlighted in blue as they move.
- In the Network panel, disable the cache and throttle to Slow 4G.
- Reload and watch for blue flashes when the fonts arrive.
Record a Trace
Record a page load in the Performance panel. The Layout shifts track shows each shift with its score. Click one to see which nodes moved. If shifts line up with font requests finishing in the Network track, the swap is responsible.
Compare Against Blocked Fonts
To isolate the font's effect, block the font requests in the Network panel and take a screenshot, then unblock and take another. Overlaying the two shows exactly how much your text reflows between fallback and web font.
Step 1: Choose a Reasonable Fallback
Before tuning numbers, pick a local fallback that's already close in style and proportion:
| Web font style | Good local fallbacks |
|---|---|
| Neutral sans-serif | Arial, Helvetica, Segoe UI, Roboto |
| Wide humanist sans-serif | Verdana, Segoe UI, Tahoma |
| Text serif | Georgia, Times New Roman, Cambria |
| Monospace | Menlo, Consolas, Courier New |
Arial and Georgia are the most predictable choices, because they're installed on nearly every desktop. Mobile systems have different fonts, so a stack such as Arial, Roboto lets Android use Roboto when Arial isn't available.
Step 2: Override the Fallback's Metrics
The @font-face descriptors size-adjust, ascent-override, descent-override and line-gap-override let you define a new font face built from a local font, but scaled and with altered vertical metrics.
@font-face {
font-family: "Lato Fallback";
src: local("Arial");
size-adjust: 97%;
ascent-override: 101%;
descent-override: 22%;
line-gap-override: 0%;
}
body {
font-family: "Lato", "Lato Fallback", Arial, sans-serif;
}
The values above are examples only; you should calculate them for your own fonts. What each descriptor does:
- size-adjust: Scales the glyphs of the fallback font by a percentage, without changing the
font-sizevalue. It's mainly used to match the average character width so lines break in the same places. - ascent-override: Sets the height above the baseline used for line layout, as a percentage of the font size.
- descent-override: Sets the depth below the baseline.
- line-gap-override: Sets the extra space the font adds between lines. Usually set to
0%to match modern web fonts.
The local() function refers to a font installed on the device, so this rule doesn't download anything. If the named font isn't installed, the face is skipped and the browser moves on to the next family in the list.
size-adjust is supported in all current major browsers. The three override descriptors arrived in Chromium and Firefox first and Safari support came later, so check current compatibility data. Where a descriptor isn't supported, the browser simply ignores it and the fallback behaves as before.
Step 3: Calculate the Values
The values come from metrics stored in the two font files. The usual approach, used by tools such as next/font and Fontaine, is:
- Size adjustment: The ratio between the average character width of the web font and the fallback, each divided by its units per em.
- Ascent override: The web font's ascender divided by its units per em, then divided by the size adjustment.
- Descent override: The same calculation for the descender.
- Line gap override: The same calculation for the line gap.
Dividing by the size adjustment matters because size-adjust also scales the overridden metrics. Without that correction the line height would be off by the same factor.
A Script to Calculate Overrides
This Python script uses fonttools to read both fonts and print a fallback rule. It uses the font's average character width from the OS/2 table, which is a reasonable approximation:
import sys
from fontTools.ttLib import TTFont
def metrics(path):
font = TTFont(path)
upm = font["head"].unitsPerEm
hhea = font["hhea"]
return {
"upm": upm,
"ascent": hhea.ascent / upm,
"descent": abs(hhea.descent) / upm,
"line_gap": hhea.lineGap / upm,
"avg_width": font["OS/2"].xAvgCharWidth / upm,
}
def fallback_rule(web_path, fallback_path, family, local_name):
web = metrics(web_path)
fb = metrics(fallback_path)
size_adjust = web["avg_width"] / fb["avg_width"]
def pct(value):
return f"{value / size_adjust * 100:.2f}%"
return (
"@font-face {\n"
f' font-family: "{family} Fallback";\n'
f' src: local("{local_name}");\n'
f" size-adjust: {size_adjust * 100:.2f}%;\n"
f" ascent-override: {pct(web['ascent'])};\n"
f" descent-override: {pct(web['descent'])};\n"
f" line-gap-override: {pct(web['line_gap'])};\n"
"}"
)
if __name__ == "__main__":
web_font, fallback_font, family, local_name = sys.argv[1:5]
print(fallback_rule(web_font, fallback_font, family, local_name))
Run it with your web font and a copy of the fallback font:
pip install fonttools brotli
python3 fallback.py fonts/lato-latin-400.woff2 /path/to/Arial.ttf "Lato" "Arial"
It prints a complete @font-face rule you can paste into your CSS. Two caveats apply:
- Average width is an approximation:
xAvgCharWidthis averaged over the font's glyphs, not weighted by how often letters appear in real text. Tools such as Capsize use a frequency-weighted average, which usually gives a closer match for body copy. - Vertical metrics vary by platform: Fonts store ascent and descent in more than one table, and operating systems don't always read the same one. The
hheavalues used here are a common choice. Always verify the result visually.
Step 4: Let a Tool Do It
You rarely need to write the maths yourself.
Next.js
next/font generates a metric-adjusted fallback automatically for both Google and local fonts. The option is on by default:
import { Lato } from "next/font/google";
export const lato = Lato({
weight: ["400", "700"],
subsets: ["latin"],
display: "swap",
adjustFontFallback: true,
});
For next/font/local, you can choose whether the generated fallback is based on Arial or Times New Roman.
Capsize
Capsize ships metrics for thousands of fonts and can create a fallback stack from them:
import { createFontStack } from "@capsizecss/core";
import lato from "@capsizecss/metrics/lato";
import arial from "@capsizecss/metrics/arial";
const { fontFamily, fontFaces } = createFontStack([lato, arial]);
// fontFamily: a font-family value including the generated fallback name
// fontFaces: a string of @font-face rules to add to your stylesheet
Fontaine
Fontaine is a plugin for Vite, Nuxt and other build tools that reads your @font-face rules and injects matching fallback faces during the build. It's useful when you'd rather not change your CSS by hand.
Step 5: Reduce How Often Swaps Happen
Metric overrides make swaps harmless; loading strategy makes them rare.
- Preload the main font: The earlier it arrives, the more likely it's ready before the first paint. Guidance is in the article on how to preload fonts.
- Use font-display: optional: The web font is used only if it's ready almost immediately; otherwise the fallback stays for that page view. That rules out late swaps entirely.
- Keep files small: WOFF2, subsetting and fewer weights all shorten the download.
- Self-host: Removing third-party connections gets fonts in sooner.
A robust combination for body text is a preloaded, subset WOFF2 with font-display: swap and a metric-matched fallback. If you want zero swap shifts regardless, switch to optional.
Other Causes Worth Checking
Font swaps aren't the only typographic source of shifts:
- Icon fonts: A missing icon font can render as an empty box or nothing at all, then grow when the font arrives. Use inline SVG with explicit dimensions.
- Late-injected font CSS: If JavaScript adds font stylesheets after the first paint, fonts are discovered even later. Load font CSS in the
head. - Synthetic bold and italic: If the real bold file loads after the regular one, text first renders in a synthesised bold and then changes width. Load the styles you need together, or use a variable font.
- Unstable line height:
line-height: normaldepends on each font's metrics. Setting an explicit unitless value, such asline-height: 1.6, makes line boxes the same height for both fonts and reduces vertical movement even before you add overrides.
body {
font-family: "Lato", "Lato Fallback", Arial, sans-serif;
font-size: 1.0625rem;
line-height: 1.6;
}
The font-size-adjust Property
The CSS font-size-adjust property is a different tool. It scales every font in the stack so they share the same x-height, which keeps fallbacks visually similar in size. It doesn't target widths or line metrics as precisely as the descriptors above, but it's a simple option when you don't control the fallback faces. Current versions of Chrome, Edge, Firefox and Safari support it.
body {
font-family: "Lato", Arial, sans-serif;
font-size-adjust: from-font;
}
The from-font keyword reads the x-height ratio from the first available font in the list, so the fallback is scaled to match the web font's x-height.
Verifying the Fix
- Turn on Layout Shift Regions and reload on a throttled, uncached connection. Text blocks should no longer flash blue when fonts arrive.
- Block the web font and compare screenshots with and without it. Line breaks should be the same or very close.
- Run Lighthouse and check the CLS figure and the layout shift diagnostics.
- Watch field data in your analytics or Search Console's Core Web Vitals report over the following weeks, since lab tests use only one device and network.
FAQ: Web Fonts and Layout Shift
Because the fallback font and the web font have different widths and vertical metrics. When the browser swaps one for the other, lines rewrap and line heights change, which moves content on the page.
It can. swap guarantees text is visible but allows a swap at any time, and if the fallback doesn't match the web font's size, that swap moves content. A metric-matched fallback or font-display: optional reduces it.
It scales the glyphs of a font face by a percentage without changing the font-size you set. On a fallback face, it's used to match the width of the web font so text wraps in the same places.
Ideally, yes, because bold and regular styles have different widths. In practice many sites tune only the regular weight, which covers most body text, and accept a small difference for bold.
No. The letterforms stay the same; only their size and spacing change. The goal is that the text occupies the same space, so the swap changes the appearance without moving anything.
It helps a lot. It self-hosts fonts, preloads them and generates a size-adjusted fallback by default. You should still check your pages, because the automatic fallback is an approximation.
Conclusion
Font-related layout shift happens because the fallback and the web font occupy different amounts of space. The fix is to make that difference disappear: choose a similar local fallback, then tune it with size-adjust, ascent-override, descent-override and line-gap-override so it matches the web font's width and line metrics. Tools such as next/font, Capsize and Fontaine can calculate the values for you.
Then make swaps rarer and earlier with preloading, small WOFF2 files and, where stability matters most, font-display: optional. Check the results with Layout Shift Regions and real-user data. With a tuned fallback and a sensible loading strategy, your fonts can swap in without moving a single line.


