completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

Fallback fonts without layout shifts

Keep a font swap from moving the page: size-adjust and the overrides on a local fallback font, the tools that compute them, and font-display: optional.

By the end, you can
  • 01
    Explain why a font swap moves the pageSay which sizes of the fallback font differ from the web font's, and how Chrome scores the move.
  • 02
    Build a fallback that fitsGive a local font size-adjust and the three overrides, with numbers from a tool like Capsize.
  • 03
    Skip the swap when it suitsKnow what font-display: optional does on a first and a second visit, and what a preload adds.

A button that jumped under Asha's thumb

Ravi's mint tea page can take orders now. Under the brewing steps, he adds two buttons: Add to cart and Save for later. The words are still in Caveat, the handwriting font from lesson 20.1. They still use font-display: swap. So until Caveat arrives, they show in Arial, a plain font that Windows and Mac computers come with.

Asha opens the page on her phone, on a slow mobile link. She reads the steps and reaches for Add to cart. Just then, Caveat arrives. The steps get shorter, and both buttons jump up. Her thumb lands on Save for later. Here is her screen, just before and just after Caveat arrived:

The mint tea page on Asha's phone, on Slow 4G, before and after Caveat swapped in. 1,222 ms: the words in Arial: Add to cart at 407.2 px from the top; a tap at 429 px lands on Add to cart. 2,223 ms: Caveat swapped in: Add to cart at 373.4 px from the top; a tap at 429 px lands on Save for later.1,222 ms: the words in ArialThe page at 1,222 ms, in ArialAdd to cart at 407.2 pxTap lands on Add to cart2,223 ms: Caveat swapped inThe page at 2,223 ms, in CaveatAdd to cart at 373.4 pxTap lands on Save for laterAsha's tapSame spot
Asha's screen as Chrome drew it. When Caveat arrived, step 1 fit on one line, and both buttons moved up 33.8 px. The spot where Add to cart had been was now on Save for later.

Lesson 20.1 saw the same jump and left it for this lesson. Why does a new font move the page? And can the fallback font, the one shown while Caveat downloads, take exactly the room Caveat will take, so that nothing moves?

The course loaded the page three times in Chrome 157. It ran Chrome on a computer, set up like Asha's phone: a phone's screen, DevTools' Slow 4G network, and a CPU 4 times slower. It kept every frame Chrome drew. It also kept every layout shift: Chrome's record of something on the screen moving on its own.

Where the buttons were, before and afterPop out in the code editor (opens in a new tab)JavaScript
// Where the buttons sat on Asha's screen, as recorded (px from the top)const addToCart = { beforeSwap: 407.2, afterSwap: 373.4, height: 44 };const saveForLater = { afterSwap: 421.4, height: 44 };const tap = addToCart.beforeSwap + addToCart.height / 2; console.log(`Add to cart moved up ${(addToCart.beforeSwap - addToCart.afterSwap).toFixed(1)} px`);console.log(`A tap at ${tap} px is on Save for later: ${tap >= saveForLater.afterSwap && tap < saveForLater.afterSwap + saveForLater.height}`);
  • Line 1 is a comment. Positions are in pixels (px) from the top of Asha's screen.
  • Line 2 is Add to cart: its top before the swap, its top after it, and its height. Chrome reported both tops itself, in the layout shift.
  • Line 3 is Save for later, after the swap.
  • Line 4 is the spot Asha aims at: the middle of Add to cart, just before the swap.
  • Lines 6 and 7 print Add to cart moved up 33.8 px and A tap at 429.2 px is on Save for later: true.

Add to cart moved up by most of its own height. The spot Asha aimed at was now on the button below it.

Real-life analogyA stand-in the same size as the star

Before a film scene, the crew sets up the lights and the camera on a stand-in. A stand-in is a person the same height and build as the star. When the star steps in, nothing needs to move. If the stand-in were a head taller, the crew would have to move every light.

In real life: The star, still on the way
In CSS: Caveat, still downloading
In real life: The stand-in, under the lights now
In CSS: The fallback font, drawn first
In real life: Lights and camera set up on the stand-in
In CSS: The page laid out in the fallback
In real life: Moving every light when the star is shorter
In CSS: A layout shift when the font swaps in
The main idea

A font swap moves the page when the fallback font takes a different amount of room than the web font. Give the fallback the web font's sizes, and the swap changes only the shapes of the letters. Or use font-display: optional, and the font never swaps in after the first paint.

The rest of the lesson takes one question at a time:

  • Why does a new font move the page? How a font swap moves the page.
  • How do the lines get the same length? size-adjust.
  • How do the letters sit at the same height? ascent-override and descent-override.
  • Where do the numbers come from? Tools that compute the overrides.
  • Can the font simply not swap? font-display: optional.

For the same ideas without a stopwatch, see Web fonts in the system design course.

How a font swap moves the page

A font takes room in two directions. Across the page, each letter has a width. Down the page, each line of text needs some height. If Arial and Caveat need different room, the page must move when one replaces the other.

Across first. Arial's letters are wider than Caveat's. Each step has 344 px for its text. In Arial, step 1 doesn't fit, so its last word wraps onto a second line. In Caveat, the whole step fits. So the steps lose a line when Caveat arrives. Everything under them moves up by one line: 33.8 px.

How does Chrome see that move? The Layout Instability API, from lesson 11.3, describes it. Before each new frame, Chrome compares where every element and every run of text starts with where it started in the last frame. Tiny moves don't count. The spec says: "Chrome has defined number of pixels to significance as 3." So a move of 3 px or more counts.

Then Chrome gives the frame a score. You met it in Core Web Vitals, lesson 3.1. The impact fraction is how much of the screen the moved things covered, before and after. The distance fraction is how far the farthest one moved, as a share of the screen's longer side. The score is one times the other.

Taking the plain page's shift apartPop out in the code editor (opens in a new tab)JavaScript
// The plain page's one layout shift, as Chrome reported itconst shift = { value: 0.0151, moved: 407.2 - 373.4 };const screen = { width: 412, height: 823 }; const distance = shift.moved / Math.max(screen.width, screen.height);console.log(`Distance fraction: ${distance.toFixed(3)}`);console.log(`Impact fraction: ${(shift.value / distance).toFixed(2)}`);
  • Line 2 holds the shift's score, as Chrome reported it, rounded. It also holds how far Add to cart moved.
  • Line 3 is the phone's screen: 412 px wide and 823 px tall.
  • Line 5 divides the move by the longer side.
  • Lines 6 and 7 print Distance fraction: 0.041 and Impact fraction: 0.37.

So the steps, the buttons and the heading's text, at their old and new places, covered about a third of the screen. They moved 4% of its height. The score was 0.0151. CLS, Cumulative Layout Shift, adds up the scores of the worst burst of shifts. There was only one shift, so the page's CLS was 0.0151, in all three loads.

Lesson 3.1 said a CLS of 0.1 or less is good. 0.0151 is far under that. But Asha still tapped the wrong button. A small score can hide a bad move, when the thing that moves is the thing someone is about to tap.

The CSS Fonts spec saw this coming. It advises authors to use "fallback fonts in their font lists that closely match the metrics of the downloadable fonts to avoid large page reflows where possible." A reflow is the browser laying the page out again.

Why it matters: Chrome counts a font swap like any other move. To stop it, the fallback has to take the same room as Caveat, across and down. The next two sections take one direction each.

size-adjust: lines as long as Caveat's

To change a local font's sizes, you first give it an @font-face rule of its own. Lesson 20.1 met these rules. One that points at a font already on the device is a fallback face.

A fallback face for ArialCSS
@font-face {  font-family: "Caveat Fallback";  src: local("Arial");}h1, p, li { font-family: "Caveat", "Caveat Fallback", sans-serif; }
  • Lines 1 to 4 are the fallback face. Line 2 gives it a family name of its own, "Caveat Fallback".
  • Line 3 is where its letters come from. local("Arial") means Arial, as it's already installed on this device. Nothing is downloaded.
  • Line 5 lists the fonts in order: Caveat first, then the fallback face, then the device's own sans-serif.

What if a device has no Arial? The CSS Fonts spec says local font faces "that are not found are ignored". Then the face has nothing to draw letters with. So the browser moves on to the next name in line 5: the device's own sans-serif. The tools section comes back to this.

This face changes nothing yet. It's the plain Arial from the opening. Now Ravi makes Arial's letters narrower.

Real-life analogyThe photocopier's zoom button

Ravi's poster for the tea shop is too wide for the noticeboard. He copies it on the photocopier at 77%. Everything on the copy shrinks by the same amount: the words, the spaces, and the gaps above and below. Now it fits the space exactly.

In real life: A poster too wide for the noticeboard
In CSS: Arial's lines, longer than Caveat's
In real life: Copying it at 77%
In CSS: size-adjust: 76.9402%
In real life: Everything on the copy shrinks by the same amount
In CSS: Every letter, with its width and its height
In real life: The copy fits the space
In CSS: Each line as long as it will be in Caveat
The fallback face, at 76.94%CSS
@font-face {  font-family: "Caveat Fallback";  src: local("Arial");  size-adjust: 76.9402%;}

Line 4 is new. size-adjust draws every letter of this face at 76.9402% of its size. The CSS Fonts spec says: "All metrics associated with this font ... are scaled by the given percentage, as are the rendered glyph images." Metrics are the font's measurements, and glyphs are its letter shapes.

The spec adds that "the computed font-size ... remains unaffected." So the paragraph is still 26px in your CSS. Only the letters this one face draws get smaller.

Why 76.9402%? On average, Caveat's letters are about three quarters as wide as Arial's. The tools section shows where the number comes from. First, did it work? The course measured the page in each font, on the same phone screen:

Step 1 and Add to cart in each fontPop out in the code editor (opens in a new tab)JavaScript
// Step 1 and Add to cart in each font, measured on the page (px from the top)const fonts = {  caveat: { step1Lines: 1, addToCart: 373.4 },  arial: { step1Lines: 2, addToCart: 407.2 },  arialAt77: { step1Lines: 1, addToCart: 373.4 },}; for (const [font, { step1Lines, addToCart }] of Object.entries(fonts)) {  console.log(`${font}: step 1 on ${step1Lines} line${step1Lines === 1 ? "" : "s"}, Add to cart at ${addToCart} px`);}
  • Lines 3 to 5 hold what the course measured in Caveat, in plain Arial, and in Arial at 76.94%.
  • Lines 8 to 10 print one line for each font. The first is caveat: step 1 on 1 line, Add to cart at 373.4 px.
  • The second is arial: step 1 on 2 lines, Add to cart at 407.2 px. The last is arialAt77: step 1 on 1 line, Add to cart at 373.4 px.

At 76.94%, step 1 fits on one line, as it does in Caveat. Add to cart sits at 373.4 px in both. Here are the three pages side by side:

The mint tea page drawn in three fonts on the same phone screen. Caveat: step 1 on 1 line, Add to cart at 373.4 px. Arial: step 1 on 2 lines, Add to cart at 407.2 px. Arial at 76.94%: step 1 on 1 line, Add to cart at 373.4 px.CaveatThe page drawn in CaveatStep 1: 1 lineAdd to cart: 373.4 pxArialThe page drawn in ArialStep 1: 2 linesAdd to cart: 407.2 pxArial at 76.94%The page drawn in Arial at 76.94%Step 1: 1 lineAdd to cart: 373.4 px
The page in each font, measured on the same phone screen. In plain Arial, step 1 needs two lines, and the buttons sit lower. At 76.94%, Arial's lines are as long as Caveat's, and the buttons sit where Caveat puts them.

Now Asha's tap is safe, because the buttons don't move. Try it in your own browser. The playground draws the card in a fallback face. A second later, it swaps Caveat in and measures what moved.

Playground: swap the font in your browser
In the fallback: Plain Arial
Leaf and Kettle
Mint tea

Fresh mint leaves, picked this week. 120 grams.

  1. Warm the glass with hot water.
  2. Add a handful of leaves.
  3. Pour water just off the boil.
  4. Wait four minutes, then sip.
Add to cartSave for later
What moved
  1. StatusLoading the fonts
Try it yourself
Plain Arial

This runs in your own browser. The card is drawn in the fallback face you picked. A second later, its words switch to Caveat, and the lab measures what moved. Pick a face again to repeat it.

The card has the recorded page's sizes: 412 px wide, text at 26 px. Your numbers depend on your fonts: a device without Arial skips the fallback face and draws its own sans-serif.

Why it matters: with size-adjust alone, the steps kept their lines, and the buttons stayed where Asha aimed. But the recorded CLS wasn't zero. It went from 0.0151 down to 0.0042, in all three loads. Something still moved.

ascent-override and descent-override: letters at the same height

The layout shift on the size-adjust page lists what it moved. It wasn't the steps or the buttons. It was the text inside them. The heading's text moved up 11 px. The paragraph's text and each step's text moved up 5 px. Why would text move inside a line that stays put?

Each run of text sits on a baseline: the line the letters stand on. A font says how much room its letters need above the baseline. That's its ascent. It also says how much room they need below, for tails like g and y. That's its descent. Some fonts also ask for a line gap: extra space between lines.

Real-life analogyRuled paper with the lines in different places

Two notebooks can have their lines the same distance apart. But one leaves more room above each line, and the other more below. Copy a recipe from one into the other, and every word sits a little higher or lower. Rule both the same way, and the words land in the same places.

In real life: The line you write on
In fonts: The baseline
In real life: The room above it, for tall letters like h and k
In fonts: The ascent
In real life: The room below it, for tails like g and y
In fonts: The descent
In real life: Ruling the stand-in's paper like the star's
In fonts: ascent-override and descent-override

The ascent plus the descent makes a box for the text. Chrome tracks text by where that box starts. On Ravi's page, each step's line is 33.8 px tall: 26 px text, with a line-height of 1.3. The text box sits in the middle of that line. Here is what the course measured for step 2:

The text box inside one linePop out in the code editor (opens in a new tab)JavaScript
// One step's line is 33.8 px tall. The text box inside it, in each font, measured (px)const boxes = {  caveat: { height: 33, fromTop: 0 },  arialAt77: { height: 22, fromTop: 5 },  withOverrides: { height: 33, fromTop: 0 },}; for (const [font, box] of Object.entries(boxes)) {  console.log(`${font}: a ${box.height} px box, ${box.fromTop} px below the line's top`);}
  • Line 3 is Caveat: a 33 px box. It nearly fills the line, so it starts at the top.
  • Line 4 is Arial at 76.94%. size-adjust shrank its ascent and descent too. So its box is only 22 px tall, and starts 5 px down.
  • Line 5 is Arial with the overrides from below: a 33 px box again.
  • Lines 8 to 10 print one line for each. Arial at 76.94% prints arialAt77: a 22 px box, 5 px below the line's top.

When Caveat arrived, every 22 px box became a 33 px box, and its top moved up 5 px. That's more than Chrome's 3 px, so every run of text counted as moved. The lines themselves stayed put, so the score was small. But it wasn't zero.

One 33.8 px line of the steps, four times bigger, in three fonts. Caveat: a 33 px text box, 0 px below the line's top, with 25 px above the baseline and 8 px below it. Arial at 76.94%: a 22 px text box, 5 px below the line's top, with 18 px above the baseline and 4 px below it. Arial with the overrides: a 33 px text box, 0 px below the line's top, with 25 px above the baseline and 8 px below it.Caveatabove: 25 pxbelow: 8 pxbox: 33 px, 0 px downArial at 76.94%above: 18 pxbelow: 4 pxbox: 22 px, 5 px downArial with the overridesabove: 25 pxbelow: 8 pxbox: 33 px, 0 px down
One line of a step, four times bigger. Each font's text box is its room above the baseline plus its room below; the baseline is where the two meet. At 76.94%, Arial's box is shorter and starts lower. With the overrides, it's Caveat's box.

The fix is to give the fallback face Caveat's ascent and descent. That's what the overrides do:

The fallback face with all four numbersCSS
@font-face {  font-family: "Caveat Fallback";  src: local("Arial");  size-adjust: 76.9402%;  ascent-override: 124.7722%;  descent-override: 38.9913%;  line-gap-override: 0%;}
  • Line 4 is size-adjust, as before.
  • Line 5, ascent-override, sets the room above the baseline. Its percentage is of the font size after size-adjust: 124.7722% of 76.94% of 26 px.
  • Line 6, descent-override, sets the room below the baseline in the same way.
  • Line 7, line-gap-override, sets the line gap. Caveat has none, so it's 0%.

Does line 5 really give Arial Caveat's room? This code checks:

Line 5, worked outPop out in the code editor (opens in a new tab)JavaScript
const fontSize = 26;const sizeAdjust = 0.769402;const ascentOverride = 1.247722; console.log(`Arial's room above the baseline: ${(fontSize * sizeAdjust * ascentOverride).toFixed(2)} px`);console.log(`Caveat's room above the baseline: ${((fontSize * 960) / 1000).toFixed(2)} px`);
  • Lines 1 to 3 are the font size, size-adjust and ascent-override, as plain numbers.
  • Line 5 multiplies them. Line 6 is Caveat's own ascent: 960 units of every 1,000, at 26 px.
  • They print Arial's room above the baseline: 24.96 px and Caveat's room above the baseline: 24.96 px.

The spec says these overrides don't change font-size or line-height. "They can, however, affect the behavior of line-height: normal". With line-height: normal, the font's own sizes set each line's height. The course measured one such line. It was 33 px in Caveat, 31 px in plain Arial, 23 px in Arial at 76.94%, and 33 px with the overrides. So on a page that uses normal, the overrides keep every line the same height.

With all four numbers, CLS was 0 in all three loads. Compare the three fallbacks here:

Playground: three fallbacks on Asha's phone
What Asha saw
  1. The page at 1,222 ms: the words in Arial1,222 ms: Arial
  2. The page at 2,223 ms: the words in Caveat2,223 ms: Caveat
  1. Step 1, in the fallback then in Caveat2 lines, then 1 line
  2. Add to cart, in the fallback then in Caveat407.2 px, then 373.4 px
  3. Step 2's text box, in the fallback then in Caveat30 px tall, 1.0 px down, then 33 px tall, 0.0 px down
  4. Caveat on the screen2,223 ms
  5. Layout shifts1, at 2,216 ms
  6. What it movedbutton#save, button#add, text: Mint tea, li, li
  7. CLS0.0151
Try it yourself
Plain Arial: CLS 0.0151

The same score in all 3 loads. "text:" in the list means a run of text, named by its first words.

Recorded in Chrome 157, on a phone's screen, with a CPU 4 times slower. DevTools' Slow 4G. The frames are from the load whose font arrived in the middle. Positions are in px from the top of the screen.

Read the table across: each row is one line of the fallback face.

Four lines in a fallback face
LineWhat does it set?A percentage of what?What does it fix?
size-adjustHow big this face draws every letter, with its widths and heights.The letters' own size. 100% leaves them as they are.Lines of a different length, and different line breaks.
ascent-overrideThe room above the baseline.The font size, after size-adjust.Text that sits higher or lower in its line.
descent-overrideThe room below the baseline.The font size, after size-adjust.The same, from below.
line-gap-overrideThe extra space the font asks for between lines.The font size, after size-adjust.Lines of a different height when line-height is normal.

Safari is different. It added size-adjust in version 17. Its released versions don't support the three overrides. WebKit, Safari's engine, has added them, and they're in Safari Technology Preview, Apple's test version. A browser skips CSS lines it doesn't know. So in Safari, Ravi's face works like the size-adjust-only version.

Why it matters: size-adjust fixes the length of the lines. The overrides fix where the text sits in them. With both, Caveat replaced Arial and nothing on the page moved.

Tools that compute the overrides

Nobody guesses 76.9402%. A tool reads both fonts' measurements and does the maths. Chrome's article on font fallbacks gives the formulas:

  • size-adjust is the web font's average letter width, divided by the fallback's.
  • Each override is the web font's own measurement, divided by its em square times size-adjust.

The em square is the grid a font is drawn on. It's 1,000 units tall for Caveat, and 2,048 for Arial.

Real-life analogyA tailor's measurements

A tailor doesn't guess your size. She measures you, writes the numbers down, and alters the suit to match. For the next customer she measures again, because no two people need the same changes.

In real life: The customer's measurements
In fonts: The web font's ascent, descent, line gap and letter width
In real life: The tape measure
In fonts: A tool that reads the font file
In real life: The note for the alterations
In fonts: The four numbers for the fallback face
In real life: Measuring each new customer
In fonts: One face for each local font

The average letter width needs care. An average over a to z would count z as often as e. But e is far more common in real text. Capsize, a font-sizing library, weights each letter by how often it appears. Its docs say the average "is derived from character frequencies in written language." For Caveat, it's 343 units of 1,000. For Arial, it's 913 of 2,048.

The formulas, with Caveat and ArialPop out in the code editor (opens in a new tab)JavaScript
// Average letter widths, as a share of the font size, from each font's measurementsconst caveat = 343 / 1000;const arial = 913 / 2048;const sizeAdjust = caveat / arial; console.log(`size-adjust: ${(sizeAdjust * 100).toFixed(4)}%`);console.log(`ascent-override: ${((960 / 1000 / sizeAdjust) * 100).toFixed(4)}%`);console.log(`descent-override: ${((300 / 1000 / sizeAdjust) * 100).toFixed(4)}%`);
  • Lines 2 and 3 turn each average into a share of the font size.
  • Line 4 divides one by the other. That's size-adjust.
  • Lines 7 and 8 divide Caveat's ascent, 960, and its descent, 300, by its em square times size-adjust.
  • It prints size-adjust: 76.9402%, ascent-override: 124.7722% and descent-override: 38.9913%.

Ravi didn't write that by hand. He asked Capsize, which read Caveat's own file:

Asking Capsize for the fallback faceJavaScript
import { readFileSync } from "node:fs";import { createFontStack } from "@capsizecss/core";import { fromBuffer } from "@capsizecss/unpack";import arial from "@capsizecss/metrics/arial"; const caveat = await fromBuffer(readFileSync("caveat.woff2"));const { fontFamily, fontFaces } = createFontStack([caveat, arial]);console.log(fontFamily);console.log(fontFaces);
  • Lines 2 to 4 load three parts of Capsize. @capsizecss/core does the maths. @capsizecss/unpack reads a font file. @capsizecss/metrics holds the measurements of common fonts, like Arial.
  • Line 6 reads Caveat's own WOFF2 file.
  • Line 7 builds the font stack: Caveat, then a fallback face for Arial.
  • Lines 8 and 9 print the font list and the face:
What Capsize printedText
Caveat, "Caveat Fallback", Arial
@font-face {
  font-family: "Caveat Fallback";
  src: local('Arial'), local('ArialMT');
  ascent-override: 124.7722%;
  descent-override: 38.9913%;
  line-gap-override: 0%;
  size-adjust: 76.9402%;
}

Line 1 is the list for font-family. Lines 5 to 8 are the four numbers from before. Line 4 names Arial twice: by its full name, and by its PostScript name, ArialMT, a second name stored in the font file. The spec says local() matches only those two names, and "Which type of name is used varies by platform and font". So naming both is safer.

Other tools agree. fontaine, a library for build tools, and Next.js's next/font both wrote the same numbers for Caveat over Arial. next/font rounds them, to size-adjust: 76.94%. Its docs say adjustFontFallback "sets whether an automatic fallback font should be used to reduce Cumulative Layout Shift. The default is true." Optimizing fonts, in the Next.js course, shows it at work.

One face isn't enough. Chrome's article recommends Arial for sans-serif fonts, and Times New Roman for serif ones. Then it warns: "However, neither of these fonts is available on Android (Roboto is the only system font on Android)." On an Android phone, the Arial face is skipped, and the phone draws its own font at full size. So the tools write one face for each local font, each with its own numbers.

Pick a local font to see the face Capsize wrote for it:

Playground: one fallback face for each local font
Capsize's fallback face for ArialCSS
@font-face {
  font-family: "Caveat Fallback";
  src: local('Arial'), local('ArialMT');
  ascent-override: 124.7722%;
  descent-override: 38.9913%;
  line-gap-override: 0%;
  size-adjust: 76.9402%;
}
The maths
  1. Caveat's average letter: 343 of 1000 units0.3430 of the font size
  2. Arial's average letter: 913 of 2048 units0.4458 of the font size
  3. size-adjust: the first divided by the second76.9402%
  4. ascent-override: 960 / (1000 x size-adjust)124.7722%
  5. descent-override: 300 / (1000 x size-adjust)38.9913%
  6. line-gap-override: 0 / (1000 x size-adjust)0%
Try it yourself
Caveat over Arial: size-adjust 76.9402%

Each local font has its own average letter width and its own sizes, so each needs its own four numbers. A line the face would not change is left out.

The measurements come from @capsizecss/metrics 4.4.0 and Caveat's own file. The CSS is what createFontStack from @capsizecss/core 4.1.3 printed.

Why it matters: the numbers come from the fonts' own measurements, so a tool gets them right in a second. Give each kind of device a face of its own, or some visitors get no fix at all.

font-display: optional: no swap at all

Lesson 20.1 met font-display: optional. The font is used only if it's ready for the first paint. If it isn't, the fallback stays for the whole visit. The CSS Fonts spec makes a promise about it: "An optional font must never cause the layout of the page to "jump" as it loads in."

Real-life analogyThe late guest comes to the next dinner

At a family dinner, everyone sits down at seven. A guest who is late doesn't squeeze in during the soup, so nobody has to move their chair. They come next week instead, and their seat is ready from the start.

In real life: Dinner served at seven
In CSS: The first paint
In real life: A guest who arrives after the soup
In CSS: A font that arrives after the first paint
In real life: No squeezing in, so nobody moves their chair
In CSS: No swap, so nothing on the page moves
In real life: A seat ready at next week's dinner
In CSS: The font, from the cache, on the next visit
Caveat, only if it's ready in timeCSS
@font-face {  font-family: "Caveat";  src: url("caveat.woff2") format("woff2");  font-display: optional;}

Line 4 is the only change from Ravi's swap rule. Chromium's source code says how Chrome treats it: "we should never render invisible fallback for 'font-display: optional'." So the words show at once, in Arial. Once text is painted in Arial, Caveat isn't swapped in, even when it arrives.

The course opened the optional page twice in the same browser, with the plain Arial face. Then it added a preload, the line from lesson 20.2:

A preload for the fontHTML
<link rel="preload" href="caveat.woff2" as="font" type="font/woff2" crossorigin><link rel="stylesheet" href="styles.css">

Line 1 asks for the font while the HTML is read. Chrome also holds the first paint a little for a preloaded font. Its source sets the wait at 100 ms at most, after the other files that block rendering are in. The course loaded this page on Slow 4G, and on DevTools' Fast 4G.

optional, four waysPop out in the code editor (opens in a new tab)JavaScript
// font-display: optional on Asha's phone, as recorded (ms after she opened the page)const visits = {  "first visit": { fontArrived: 2202, firstPaint: 1228, inCaveat: false },  "second visit": { fontArrived: 599, firstPaint: 613, inCaveat: true },  "preload, Slow 4G": { fontArrived: 1600, firstPaint: 1315, inCaveat: false },  "preload, Fast 4G": { fontArrived: 449, firstPaint: 488, inCaveat: true },}; for (const [visit, { fontArrived, firstPaint, inCaveat }] of Object.entries(visits)) {  console.log(`${visit}: font at ${fontArrived} ms, first paint at ${firstPaint} ms, ${inCaveat ? "Caveat" : "Arial"} all the way`);}
  • Lines 3 to 6 hold, for each load, when the font was ready, when the words were first painted, and whether they were in Caveat.
  • Line 10 prints one line for each. The first visit prints first visit: font at 2202 ms, first paint at 1228 ms, Arial all the way.
  • The second visit prints second visit: font at 599 ms, first paint at 613 ms, Caveat all the way.
  • The preload on Slow 4G prints preload, Slow 4G: font at 1600 ms, first paint at 1315 ms, Arial all the way.
  • The preload on Fast 4G prints preload, Fast 4G: font at 449 ms, first paint at 488 ms, Caveat all the way.

Four findings:

  • The first visit stayed in Arial. The font came almost a second after the first paint, so it wasn't used. CLS was 0.
  • The second visit was in Caveat from the start. The font came from the cache, with 0 bytes over the network, before the first paint.
  • The preload on Slow 4G wasn't enough. The words came a little later: between 1,312 and 1,315 ms in the three loads, against 1,219 to 1,250 ms without the preload. The font still needed until 1,600 ms.
  • The preload on Fast 4G was in time. The font was ready before the first paint, so even the first visit was in Caveat. Nothing moved.
The mint tea page with font-display: optional. First visit: at 1,228 ms, Arial for the whole visit. Second visit: at 613 ms, Caveat from the first paint. Preload, Fast 4G: at 488 ms, Caveat from the first paint.First visitThe page at 1,228 ms, in Arial1,228 msArial for the whole visitSecond visitThe page at 613 ms, in Caveat613 msCaveat from the first paintPreload, Fast 4GThe page at 488 ms, in Caveat488 msCaveat from the first paint
Nothing swapped. On the first visit, Caveat missed the first paint, so the page kept Arial. On the second visit it came from the cache, and with a preload on Fast 4G it arrived in time.

Pick a visit to see its frames and numbers:

Playground: font-display: optional, visit by visit
What Asha saw
  1. The page at 1,228 ms: the words in Arial1,228 ms: Arial
  1. NetworkDevTools' Slow 4G
  2. Font asked for1,182 ms, by the stylesheet
  3. Font ready2,202 ms, 75,232 bytes
  4. First paint1,228 ms
  5. The words, all visit longArial
  6. CLS0
Try it yourself
First visit: the words in Arial

optional uses Caveat only if it's ready for the first paint. Otherwise the page keeps Arial for the whole visit, and nothing swaps.

Recorded in Chrome 157, on a phone's screen, with a CPU 4 times slower. Each strip is the load whose font was ready in the middle, of 3. Times are in ms after Asha opened the page.

Why it matters: optional trades the font for a page that keeps still. On a slow first visit, visitors read Arial, and nothing jumps. They get Caveat once it can be there from the first paint. If the font must show on the first visit, use swap with a fallback face that fits.

Which fix does each problem need?

For each problem, ask what is different. Is a line a different length? Does text sit at a different height? Or should the font never swap at all?

Which fix does it need?
  • Step 1 takes two lines in Arial and one in Caveat
  • A paragraph in the fallback is longer, so the buttons below it start lower
  • The lines stay put, but every line's letters move up a few pixels at the swap
  • A heading with line-height: normal is 31 px tall in Arial and 33 px in Caveat
  • A long article whose text reads just as well in any font
  • Visitors on slow links, where a jump is worse than a plain font
Try it yourself
0 of 6 correct

Sort each problem into the fix that suits it.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "size-adjust changes the font-size." No. The computed font-size stays the same. Only the letters that one face draws get bigger or smaller.
  • "The overrides go in the web font's @font-face rule." No. They go in the fallback face, the one with local(). They make the local font take the web font's room.
  • "With a fixed line-height, only size-adjust matters." No. With a line-height of 1.3, size-adjust alone still scored 0.0042. The text boxes moved 5 px inside their lines.
  • "Arial is on every device." No. Android phones don't have it. A local() font that isn't found is skipped, and so are its numbers.
  • "A CLS under 0.1 means nothing important moved." No. The plain page scored 0.0151, and Add to cart moved 33.8 px under Asha's thumb.
  • "With optional, visitors never see the web font." No. On the second visit, Caveat was there from the first paint.

Practice exercises

Answer each one first, then open the hints if you need them.

Exercise 1 · Warm-upA serif fallback

Ravi tries Times New Roman as the local font. What does the last line print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
const caveat = 343 / 1000;
const timesNewRoman = 832 / 2048;

console.log(`size-adjust: ${((caveat / timesNewRoman) * 100).toFixed(4)}%`);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upIts ascent-override

    With that size-adjust, what does the last line print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    const sizeAdjust = 0.844308;
    const caveatAscent = 960;
    const unitsPerEm = 1000;
    
    console.log(`ascent-override: ${((caveatAscent / (unitsPerEm * sizeAdjust)) * 100).toFixed(2)}%`);

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeWhen did Caveat appear?

      In the recorded page with all four numbers, when did the frame in Caveat appear, in ms?

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeThe line in the wrong rule

        Ravi's Caveat looks smaller than before, and the page still moves. Which line is in the wrong rule?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        @font-face {
          font-family: "Caveat";
          src: url("caveat.woff2") format("woff2");
          font-display: swap;
          size-adjust: 76.9402%;
        }
        @font-face {
          font-family: "Caveat Fallback";
          src: local("Arial");
        }

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeThis site's own fonts

          Which font-display value do this site's own @font-face rules use?

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeThe longest wait for a preloaded font

            Chrome holds the first paint for a preloaded font. At most, until how many ms after the visit started?

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask which room is different: the width of the letters, or their height. Or is there no swap at all?

              Fallback fonts quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does this distance check print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const before = { top: 407.2 };
                const after = { top: 373.4 };
                const screen = { width: 412, height: 823 };
                
                console.log((before.top - after.top) / Math.max(screen.width, screen.height) > 0.04);

                Choose an answer to see the explanation.

              2. Question 2 of 7What does size-adjust: 76.9402% change in a fallback face?

                Choose an answer to see the explanation.

              3. Question 3 of 7In the recording, why did the page with only size-adjust still have a small layout shift?

                Choose an answer to see the explanation.

              4. Question 4 of 7Where do size-adjust and the three overrides go?

                Choose an answer to see the explanation.

              5. Question 5 of 7Why do the tools add a second fallback face, for Roboto?

                Choose an answer to see the explanation.

              6. Question 6 of 7With font-display: optional, what happened on the second visit in the recording?

                Choose an answer to see the explanation.

              7. Question 7 of 7Which of the four lines does a released Safari use?

                Choose an answer to see the explanation.

              Key takeaways

              • A font swap moves the page when the fallback takes different room: in the length of its lines, or the height of its text.
              • A fallback face gives a local font its own @font-face rule. size-adjust scales its letters to the web font's width.
              • ascent-override, descent-override and line-gap-override give it the web font's heights. With all four, CLS was 0.
              • Capsize, fontaine and next/font compute the numbers. Write one face for each local font, because Android has no Arial.
              • font-display: optional never swaps: Arial on a slow first visit, Caveat once the font is ready for the first paint.

              Remember the one-liner.
              Make the stand-in the star's size: a fallback face with size-adjust and the overrides lets the font swap without moving a thing.

              Sources

              The facts on this page come from these standards, docs and source code:

              Coming next: When to skip web fonts. A font the device already has needs no download and no swap. Next, when that's the better choice.

              CompleteFrontend Clear concepts. Working examples.