Type something to search...
How to prevent layout shift caused by web fonts?

How to prevent layout shift caused by web fonts?

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

  1. Open DevTools, open the Command Menu with Ctrl+Shift+P or Cmd+Shift+P, and run Show Rendering.
  2. Tick Layout Shift Regions. Areas that shift are highlighted in blue as they move.
  3. In the Network panel, disable the cache and throttle to Slow 4G.
  4. 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 styleGood local fallbacks
Neutral sans-serifArial, Helvetica, Segoe UI, Roboto
Wide humanist sans-serifVerdana, Segoe UI, Tahoma
Text serifGeorgia, Times New Roman, Cambria
MonospaceMenlo, 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-size value. 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:

  1. Size adjustment: The ratio between the average character width of the web font and the fallback, each divided by its units per em.
  2. Ascent override: The web font's ascender divided by its units per em, then divided by the size adjustment.
  3. Descent override: The same calculation for the descender.
  4. 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: xAvgCharWidth is 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 hhea values 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: normal depends on each font's metrics. Setting an explicit unitless value, such as line-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

  1. Turn on Layout Shift Regions and reload on a throttled, uncached connection. Text blocks should no longer flash blue when fonts arrive.
  2. Block the web font and compare screenshots with and without it. Line breaks should be the same or very close.
  3. Run Lighthouse and check the CLS figure and the layout shift diagnostics.
  4. 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.

Share :

Related Posts

How to make typography accessible?

How to make typography accessible?

You make typography accessible by choosing clear typefaces, setting text in relative units so it scales with user preferences, giving it enough colou

Dive Deeper
What are the parts of a letterform?

What are the parts of a letterform?

A letterform is the shape of a single letter, and typographers break it down into named parts. The main ones are the stem (the main vertical

Dive Deeper
What are ascenders, descenders and the baseline?

What are ascenders, descenders and the baseline?

The baseline is the invisible line that letters sit on. Ascenders are the parts of lowercase letters that rise above the x-height, such as th

Dive Deeper