Skip to content
iD-SIGN
Contact us

How to prevent CLS when loading web fonts

When the chosen font arrives, text reflows and the page jumps. Here is how to set up a fallback font so that nothing shifts and the right font is still always used.

9 min readpublished 11/2026

In this article 10 chapters
  1. The short version
  2. The font-display value will not remove the shift
  3. A fallback font with matching metrics
  4. How to calculate the values
  5. Every weight and every system separately
  6. Safari and line height
  7. Preload just one weight
  8. Smaller files arrive sooner
  9. How to measure the result
  10. Checklist

Until the chosen font has downloaded, the browser sets the text in a fallback font. Its characters have different widths, so lines break elsewhere, blocks change height and everything below them moves once the font arrives. The reader loses their place in the text and Cumulative Layout Shift (CLS) goes up.

The fix is in CSS: you adjust the fallback font so that it takes up the same space as the right one. On an article page of one of our websites, this lowered CLS from 0.152 to 0.025, and preloading the font brought it to 0. A value of 0.1 or less is considered good.

The short version

  • Keep font-display: swap.
  • Define the fallback font through @font-face with size-adjust and overridden metrics, separately for each weight.
  • Add separate families for Windows and Apple, Android and Linux.
  • Set a numeric line height everywhere.
  • Preload just one weight, the one used for body text.
  • Measure with an empty cache and a delayed font.

The font-display value will not remove the shift

The first idea is usually to change font-display. These are the results on six real pages at four widths, with the font delayed by 1.5 seconds, in Chrome and without a fallback font with adjusted metrics. The numbers cover only the shifts after the font arrived.

  • swap: average CLS 0.007, at most 0.042. Text is visible straight away and reflows when the font arrives.
  • block: average CLS 0.007, at most 0.041. It only seems to help. Invisible text is also laid out using the fallback font, so the shift remains and the reader sees nothing for a while.
  • optional: CLS 0. A font that does not arrive in time is not used at all for that page view, so a visitor on a slow connection sees the whole page in a system font.

optional is a sensible choice if the chosen font does not matter that much to you. If it must always be used, stay with swap and adjust the fallback font. On the same pages, CLS then fell to at most 0.001.

A fallback font with matching metrics

The fallback font is an ordinary @font-face that points to a system font through local() instead of a file. Descriptors adapt it to the chosen font:

  • size-adjust scales the font so that the width of the text fits.
  • ascent-override and descent-override set the space above and below the baseline.
  • line-gap-override sets the gap between lines, usually to 0.

An example for the Barlow font and Arial, in regular and bold:

css
@font-face {
    font-family: "Barlow Fallback";
    font-weight: 100 400;
    src: local("Arial"), local("Helvetica Neue"), local("Liberation Sans");
    size-adjust: 94.1%;
    ascent-override: 106.3%;
    descent-override: 21.3%;
    line-gap-override: 0%;
}
@font-face {
    font-family: "Barlow Fallback";
    font-weight: 600 900;
    src: local("Arial Bold"), local("Arial-BoldMT"), local("Liberation Sans Bold");
    size-adjust: 90.3%;
    ascent-override: 110.7%;
    descent-override: 22.1%;
    line-gap-override: 0%;
}

Then put the family right after the chosen font:

css
body {
    font-family: "Barlow", "Barlow Fallback", system-ui, sans-serif;
}

How to calculate the values

size-adjust is the ratio of the widths of the same text. Measure on text in the language of your site, because a sample with diacritics comes out differently from an English one. On a page that loads the chosen font, run this in the console:

javascript
const sample = 'The quick brown fox jumps over the lazy dog. Paste ordinary text from your site here.';
const context = document.createElement('canvas').getContext('2d');
const width = (font) => {
    context.font = font;
    return context.measureText(sample).width;
};

await document.fonts.load('400 100px Barlow', sample);
console.log(width('400 100px Barlow') / width('400 100px Arial') * 100);

For the bold weight, change 400 to the weight of the chosen font and measure Arial at weight 700.

You can read the metrics of the chosen font with the fontTools package:

python
from fontTools.ttLib import TTFont

font = TTFont('barlow-400.woff2')
print(font['head'].unitsPerEm, font['hhea'].ascent, font['hhea'].descent, font['hhea'].lineGap)

Barlow returns 1000 1000 -200 0, meaning 100% of the font size above the baseline and 20% below it. size-adjust also scales the overridden metrics, so you have to divide them by the same ratio:

text
ascent-override  = 100% / 0.941 = 106.3%
descent-override =  20% / 0.941 =  21.3%

The Capsize and Fontaine tools can calculate these values too. Do check the result on text in your own language, though.

Every weight and every system separately

This is usually where the remaining shift hides, the one that is hard to explain at first sight.

Bold has a different ratio. Arial against Barlow came out at 94.1% for regular and 90.3% for bold. If you use a single value, headings break differently. For bold, also list local("Arial Bold"), otherwise the browser merely fakes bold from the regular weight.

The medium weight can break elsewhere. Weight 500 usually appears on buttons and in menus, where text only just fits on one row. When the fallback font used the regular metrics, buttons in one page header wrapped after the font arrived, with a shift of 0.073. A separate block for weight 500 with a value a few tenths higher solved it.

Android does not have Arial. Without its own family the fallback font does not work there, and text is 10 to 15% wider. The same goes for Linux without Liberation Sans, where DejaVu Sans is used. On the contact page of one website, a sticky bar grew while set in the fallback font, and CLS reached 0.214. Lighthouse in Docker may run without Arial, so it sometimes reports a shift you cannot see on your own computer.

So add more families. The browser uses the first one whose local() it finds on the device:

css
@font-face {
    font-family: "Barlow Fallback Roboto";
    font-weight: 100 400;
    src: local("Roboto"), local("Roboto-Regular");
    size-adjust: 95.9%;
    ascent-override: 104.3%;
    descent-override: 20.9%;
    line-gap-override: 0%;
}
@font-face {
    font-family: "Barlow Fallback DejaVu";
    font-weight: 100 400;
    src: local("DejaVu Sans"), local("DejaVuSans");
    size-adjust: 83.9%;
    ascent-override: 119.2%;
    descent-override: 23.8%;
    line-gap-override: 0%;
}
css
body {
    font-family: "Barlow", "Barlow Fallback", "Barlow Fallback Roboto", "Barlow Fallback DejaVu", system-ui, sans-serif;
}

local() looks for the exact font name. In our test on Linux with Liberation Sans, font-family: Arial worked because the system redirects the name, but local("Arial") failed. So list local("Liberation Sans") separately.

Safari and line height

Safari has supported size-adjust since version 17, but older versions ignore ascent-override, descent-override and line-gap-override. You cannot detect this with @supports, because @font-face descriptors cannot be tested that way.

On its own, size-adjust actually does harm without a numeric line height. The values below come from a text test page in Chrome, each from 30 loads at six widths. Chrome with the other descriptors left out stands in for Safari here, so this is not a measurement in Safari itself:

  • line-height: normal, no adjustments: average CLS 0.028
  • line-height: normal, size-adjust only: 0.056
  • line-height: normal, all descriptors: 0.019
  • numeric line height, size-adjust only: 0.020
  • numeric line height, all descriptors: 0.019

When text has a numeric line height, that value sets the height of the block, not the font metrics. The difference between size-adjust alone and all the descriptors then nearly disappears. So set line-height as a number on all text, including buttons and headings. A value inherited from body is enough as long as nothing overrides it with normal. Watch out for buttons and form controls, which often have normal already in the browser's default styles.

Preload just one weight

Without a preload, the browser only learns about the font after downloading and processing the stylesheet. A preload gives it a head start, so the font sometimes arrives before the first paint and no swap happens at all.

html
<link rel="preload" as="font" type="font/woff2" crossorigin href="/build/barlow-400.woff2">
  • crossorigin is required even on your own domain. Browsers fetch fonts in CORS mode, and without this attribute the preloaded file would be discarded and downloaded again.
  • Preload only the body text weight. On slow connections, four preloaded files took bandwidth from our render-blocking stylesheet and delayed LCP. Headings arrive a moment later, but with matching metrics they barely move.
  • A preload is an addition, not the solution. When styles arrived after 300 ms and the font after 200 ms, the page stayed free of shifts in 24 of 30 loads, compared with just 5 without a preload. When the font took 1.5 seconds, it hardly helped: average CLS 0.022 against 0.023.

Smaller files arrive sooner

The sooner the font arrives, the shorter the time the reader sees the fallback. Host it on your own domain, because an external service adds another connection and passes IP addresses to a third party.

Packages like Fontsource split a font into latin and latin-ext subsets. The diacritics of languages such as Czech are only in the second one, so a Czech page downloads two files per weight. You can merge them into one:

bash
pip install fonttools brotli
fonttools ttLib.woff2 decompress -o latin.ttf barlow-latin-400-normal.woff2
fonttools ttLib.woff2 decompress -o ext.ttf barlow-latin-ext-400-normal.woff2
fonttools merge --output-file=merged.ttf latin.ttf ext.ttf
fonttools subset merged.ttf --unicodes="U+0000-017F,U+2000-206F,U+20AC,U+2122" --layout-features='*' --flavor=woff2 --output-file=barlow-400.woff2

For Barlow, this turned two files of 21.7 and 13.9 kB into one of 22.8 kB. Then set a unicode-range in @font-face that matches the contents of the file.

How to measure the result

On your own computer you can easily miss the shift, because the font is cached and your connection is fast.

  1. Block the font. In Chrome DevTools, open Network, right-click the .woff2 file and choose Block request URL. The page stays in the fallback font and you can see whether line breaks and heights match.
  2. Check the critical spots. Headings, buttons in one row, navigation and sticky bars tell you the most, at both phone and desktop width.
  3. Measure the swap. Turn on Disable cache and watch Layout shifts in the Performance panel. Set a slow connection so that the font arrives only after the first paint.
  4. Test another system too. Android and Linux have different system fonts, so a result from Windows or macOS says nothing about them.
  5. Check data from visitors. Lighthouse measures one load in one environment. What counts is the CLS of real visitors in the Chrome UX Report or Search Console, which evaluate the 75th percentile.

Matching metrics reduce the shift but do not guarantee zero. The average width can fit and a particular paragraph can still break one line differently. On the test page, over 30 loads, 81 blocks changed height after the swap with no adjustments and 32 with adjusted metrics. That is why you should check the result on your own pages and not rely on the calculation alone.

Checklist

  • font-display: swap if the chosen font must always be used
  • a fallback family with size-adjust, ascent-override, descent-override and line-gap-override
  • separate values for regular, medium and bold
  • separate families for Arial, Roboto and DejaVu Sans
  • local() including the Liberation Sans name and the bold weights
  • a numeric line height on all text
  • a preload for the body text weight only, with the crossorigin attribute
  • the font on your own domain, with the characters of your language in one file
  • a check with the font blocked, at both phone and desktop width
  • the CLS of real visitors in the Chrome UX Report or Search Console

Tags Frontend

Share LinkedIn Facebook

Need more than an article?

A few sentences about what you are dealing with are enough.