notes 6 min typography
Invisible text, then a swap: the web font flash
Text that is missing for a second and text that changes typeface in front of you look like two bugs. They are one timer, set by one descriptor, and every page with a webfont has chosen a setting for it - most of them by not choosing.
A webfont is requested late - after the HTML, after the stylesheet, after layout finds text that wants it - so on a first visit there is always a moment when the browser has words to draw and no font to draw them in. It has two options, and the two old names describe them:
- FOIT
- Flash of invisible text. The browser lays the text out and paints nothing, holding the space until the font arrives. The page looks finished except that the words are missing.
- FOUT
- Flash of unstyled text. The browser paints with a fallback straight away and swaps in the webfont when it lands. Readable at once, and visibly different a moment later.
Neither is a malfunction. Both are the browser following an instruction, and the instruction is font-display.
The three periods #
The CSS Fonts specification divides the time after a face is first needed into three periods, and every font-display value is a different length for each:
- Block period. The text is laid out with a fallback's metrics but painted invisibly. If the font arrives now it is used, and nobody saw anything else.
- Swap period. The fallback is painted. If the font arrives now, the text is redrawn in it - the visible swap.
- Failure period. The browser stops waiting. Whatever is painted stays painted, even if the file turns up later.
- auto
- The browser decides. In current engines it behaves like
block, which is why a page that never set the descriptor still hides its text. - block
- A short block period - the specification suggests three seconds - and then an unlimited swap. Invisible text first, a late swap if the network is slow.
- swap
- A block period close to zero, then an unlimited swap. Text is visible at once, and the webfont replaces it whenever it arrives, however late.
- fallback
- A tiny block period and a short swap period of about three seconds. If the font misses that window the fallback stays for this page view.
- optional
- A tiny block period and no swap at all. The font is used only if it is ready almost immediately; otherwise the page keeps the fallback, and the file finishes downloading for the next page to use from cache.
Read that way, the worst case of block is both flashes in sequence: three seconds of nothing, then the fallback, then a swap when the file finally arrives. Invisible text does not buy you a swap-free page on a slow connection.
Choosing a value per font, not per site #
Most stylesheets set one value on every @font-face and move on. The right value depends on what the face is doing on the page:
- Body text
swaporoptional. Somebody arrived to read, and three seconds of blank paragraphs is the worst available outcome.optionaltrades the brand face on a slow first visit for a page that never reflows.- Headings and display type
swapwhen the face is the identity,fallbackwhen a late swap of a large headline would be more disruptive than not swapping. Large type is where a reflow is most visible.- Icon fonts
block. An icon font drawn in a fallback shows its raw characters - a private-use code point as an empty box, or a ligature name likearrow_forwardspelled out in full. Invisible for a moment is better than wrong.
@font-face {
font-family: 'Brand';
src: url('/fonts/brand-text.woff2') format('woff2');
font-weight: 400;
font-display: optional;
}
@font-face {
font-family: 'Brand';
src: url('/fonts/brand-display.woff2') format('woff2');
font-weight: 800;
font-display: swap;
}
For fonts loaded through a hosted stylesheet the descriptor is set by whoever wrote that stylesheet. Google Fonts takes it as a parameter - &display=swap on the CSS URL - and a link copied without one inherits the provider's default.
Flashes that are not about the first visit #
A flash on a cold cache is expected. A flash on the second page of a visit, or on every navigation, means something is putting a delay back in front of a font the browser should already have:
-
No cache lifetime on the font files. Without a long
Cache-Controlmax-age a file may be revalidated on navigation, which is a round trip before it can be used. Font URLs that carry a content hash can be cached for a year. -
The
@font-facerule arrives late. CSS-in-JS libraries and lazily loaded component styles can inject the declaration only after the page has hydrated, so the browser does not know the font exists until after first paint - on every load. -
A chain of stylesheets. An
@importinside a stylesheet that is itself linked from another origin is three requests in series before the font file is even named. -
A different
unicode-rangesubset. A page with an accented name or a different script needs a subset the previous page never requested, and that subset flashes on its own.
The cures for the first-visit case - a preload hint for the one face above the fold, and a fallback face with size-adjust and metric overrides so the swap does not move anything - are set out in an earlier note on declared and rendered fonts. They are complementary: font-display decides when the swap happens, metric overrides decide whether anybody notices.
Watching it happen #
The flash is easy to miss on a fast machine with a warm cache, which is the machine most pages are checked on. Disable the cache, throttle the connection to a slow mobile profile, and reload. Then compare when each font finished against when text was first painted:
const fcp = performance.getEntriesByName('first-contentful-paint')[0]?.startTime ?? 0;
performance.getEntriesByType('resource')
.filter((r) => /\.(woff2?|ttf|otf)(\?|$)/.test(r.name))
.map((r) => ({
font: r.name.split('/').pop(),
done: Math.round(r.responseEnd),
afterPaint: r.responseEnd > fcp,
}));
// afterPaint: true means the first paint happened without
// that face - as invisible text or as a fallback, depending
// on its font-display.
A font that finished after the first paint and is declared with block or auto was invisible text for that long. One declared with swap was a fallback, and a swap followed. If the swap moved anything, it will appear as a layout shift on the text block that reflowed.
Font faces lists every @font-face with what it declares, so a missing or inconsistent font-display is one column to read rather than a stylesheet to search. Font loading compares what was declared with what is being painted, and Shifts attributes the reflow, if there was one, to the element that moved.
Written by Ján Turský
Building LoupeKit and other browser tools out of Bratislava, under Apptiary.
tools in this note