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.
- 01Explain 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.
- 02Build a fallback that fitsGive a local font size-adjust and the three overrides, with numbers from a tool like Capsize.
- 03Skip 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:
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 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 pxandA 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.
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
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.
// 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.041andImpact 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.
@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.
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
@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 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 isarialAt77: 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:
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.
Fresh mint leaves, picked this week. 120 grams.
- Warm the glass with hot water.
- Add a handful of leaves.
- Pour water just off the boil.
- Wait four minutes, then sip.
- StatusLoading the fonts
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.
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.
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:
// 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.
The fix is to give the fallback face Caveat's ascent and descent. That's what the overrides do:
@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:
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 pxandCaveat'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:
1,222 ms: Arial
2,223 ms: Caveat
- Step 1, in the fallback then in Caveat2 lines, then 1 line
- Add to cart, in the fallback then in Caveat407.2 px, then 373.4 px
- 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
- Caveat on the screen2,223 ms
- Layout shifts1, at 2,216 ms
- What it movedbutton#save, button#add, text: Mint tea, li, li
- CLS0.0151
The same score in all 3 loads. "text:" in the list means a run of text, named by its first words.
Read the table across: each row is one line of the fallback face.
| Line | What does it set? | A percentage of what? | What does it fix? |
|---|---|---|---|
size-adjust | How 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-override | The room above the baseline. | The font size, after size-adjust. | Text that sits higher or lower in its line. |
descent-override | The room below the baseline. | The font size, after size-adjust. | The same, from below. |
line-gap-override | The 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.
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.
// 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%anddescent-override: 38.9913%.
Ravi didn't write that by hand. He asked Capsize, which read Caveat's own file:
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/coredoes the maths.@capsizecss/unpackreads a font file.@capsizecss/metricsholds 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:
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:
@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%;
}- Caveat's average letter: 343 of 1000 units0.3430 of the font size
- Arial's average letter: 913 of 2048 units0.4458 of the font size
- size-adjust: the first divided by the second76.9402%
- ascent-override: 960 / (1000 x size-adjust)124.7722%
- descent-override: 300 / (1000 x size-adjust)38.9913%
- line-gap-override: 0 / (1000 x size-adjust)0%
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.
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."
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
@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:
<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.
// 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.
Pick a visit to see its frames and numbers:
1,228 ms: Arial
- NetworkDevTools' Slow 4G
- Font asked for1,182 ms, by the stylesheet
- Font ready2,202 ms, 75,232 bytes
- First paint1,228 ms
- The words, all visit longArial
- CLS0
optional uses Caveat only if it's ready for the first paint. Otherwise the page keeps Arial for the whole visit, and nothing swaps.
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?
- 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
Sort each problem into the fix that suits it.
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.
Ravi tries Times New Roman as the local font. What does the last line print?
const caveat = 343 / 1000;
const timesNewRoman = 832 / 2048;
console.log(`size-adjust: ${((caveat / timesNewRoman) * 100).toFixed(4)}%`);size-adjust: 84.4308%. Times New Roman's letters are narrower than Arial's, so Caveat over Times New Roman needs less shrinking.
With that size-adjust, what does the last line print?
const sizeAdjust = 0.844308;
const caveatAscent = 960;
const unitsPerEm = 1000;
console.log(`ascent-override: ${((caveatAscent / (unitsPerEm * sizeAdjust)) * 100).toFixed(2)}%`);ascent-override: 113.70%. A smaller shrink needs a smaller override to reach Caveat's 960 units of room above the baseline.
In the recorded page with all four numbers, when did the frame in Caveat appear, in ms?
2,225 ms. It looks almost like the frame before it: the same lines and the same buttons, with new letter shapes.
Ravi's Caveat looks smaller than before, and the page still moves. Which line is in the wrong rule?
@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");
}@font-face {
font-family: "Caveat Fallback";
src: local("Arial");
size-adjust: 76.9402%;
}Line 5. In Caveat's rule, it shrinks Caveat itself, and Arial stays too wide. Move it into the fallback face, the one with local().
Which font-display value do this site's own @font-face rules use?
swap. This site's own font, Inter, shows its fallback at once and swaps in when it arrives, like Ravi's page. The playground's faces show up in the list too.
Chrome holds the first paint for a preloaded font. At most, until how many ms after the visit started?
1,500 ms. Chrome holds the first paint for a preloaded font at most 100 ms after the other blocking files, and never later than 1.5 seconds into the visit.
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?
Question 1 of 7What does this distance check print?
Read the code, then predictconst 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.
Question 2 of 7What does size-adjust: 76.9402% change in a fallback face?
Choose an answer to see the explanation.
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.
Question 4 of 7Where do size-adjust and the three overrides go?
Choose an answer to see the explanation.
Question 5 of 7Why do the tools add a second fallback face, for Roboto?
Choose an answer to see the explanation.
Question 6 of 7With font-display: optional, what happened on the second visit in the recording?
Choose an answer to see the explanation.
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:
- Layout Instability API, W3C Web Incubator Community Group.
- Chromium 157: layout_shift_tracker.cc, the movement threshold, the Chromium project.
- Cumulative Layout Shift (CLS), web.dev.
- CSS Fonts Module Level 4: local font fallback, W3C.
- CSS Fonts Module Level 5: the size-adjust descriptor, W3C.
- size-adjust, MDN.
- CSS Fonts Module Level 4: the ascent-override, descent-override and line-gap-override descriptors, W3C.
- CSS Fonts Module Level 5: line height font metrics overrides, W3C.
- ascent-override, MDN.
- Implement ascent-override, descent-override, and line-gap-override (bug 219735), WebKit.
- Improved font fallbacks, Chrome for Developers.
- CSS size-adjust for @font-face, web.dev.
- Capsize: createFontStack, SEEK.
- Capsize: how xWidthAvg is calculated, SEEK.
- fontaine, UnJS.
- Font module: adjustFontFallback, Next.js.
- CSS Fonts Module Level 4: the font-display descriptor, W3C.
- Chromium 157: remote_font_face_source.cc, the periods for font-display: optional, the Chromium project.
- Prevent layout shifting and flashes of invisible text (FOIT) by preloading optional fonts, web.dev.
- Chromium 157: preload_helper.cc, font preloads that hold the first paint, the Chromium project.
- Chromium 157: render_blocking_resource_manager.cc, the 100 ms limit, the Chromium project.
- Chromium 157: document_loader.cc, the longest a font can hold the first paint, the Chromium project.
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.