Lesson 20 / 26
Font Loading
Linking a font file to the document, conditional downloading with subset ranges, and choosing the display behavior during loading.
Contents
The previous lesson called the font by a name: font-family: "Inter", system-ui, sans-serif. Where the file behind that name comes from did
not get asked. How does a font not installed on the system get linked
to the document, and what happens to the text while the file
downloads?
This lesson takes up font loading. The subject concerns loading order more than typography, and is an extension of the Resource Loading Order lesson in the Web Fundamentals and HTML curriculum: a font is a subresource too, and when it arrives affects the page’s reading.
Font Definition
The @font-face rule ties a family name to a file. It is not a rule
in the usual sense — it has no selector, it does not get applied to
any element. What it does is define a name that can get used in
font-family lists.
@font-face { font-family: "Station Sans"; src: url("/fonts/station-sans.woff2") format("woff2"); font-weight: 400; font-style: normal; font-display: swap; }
font-weight and font-style are not a declaration here, they are a
definition: they say which weight and style this file provides.
Separate @font-face blocks get written for different weights under
the same family name, and the browser, once font-weight: 700 gets
requested, picks the file that provides that weight.
When no file gets declared for a weight, the browser uses the closest
one and, if needed, bolds it by calculation. This behavior, mentioned
in the previous lesson, shows up when @font-face blocks get written
incompletely.
src can take multiple sources; the first one that resolves gets
used. The format() declaration lets the browser tell whether the
format is recognized without downloading the file — a source in an
unrecognized format gets skipped, not downloaded.
Subset Ranges Enable Conditional Downloading
The unicode-range definition declares which code points a file
provides. Its consequence is decisive: if the document has no
character from that range, the file does not get downloaded.
The program below defines four subset files and calculates which ones get downloaded for three texts.
// font-loading.mjs — unicode-range subset selection and the font-display timeline const subsets = [ { file: "latin.woff2", ranges: [[0x0000, 0x00ff], [0x0131, 0x0131], [0x2000, 0x206f]] }, { file: "latin-ext.woff2", ranges: [[0x0100, 0x024f], [0x1e00, 0x1eff]] }, { file: "greek.woff2", ranges: [[0x0370, 0x03ff]] }, { file: "cyrillic.woff2", ranges: [[0x0400, 0x04ff]] }, ]; function neededSubsets(text) { const needed = new Map(); for (const ch of text) { const code = ch.codePointAt(0); for (const subset of subsets) { if (subset.ranges.some(([a, b]) => code >= a && code <= b)) { if (!needed.has(subset.file)) needed.set(subset.file, new Set()); needed.get(subset.file).add(ch); break; } } } return needed; } const texts = [ "Kuzey Yamac Olcum Istasyonu", "Kuzey Yamaç Ölçüm İstasyonu", "Sıcaklık: -3,4 °C — bağıl nem %78", ]; for (const text of texts) { const needed = neededSubsets(text); console.log(`text: ${text}`); for (const [file, chars] of needed) { console.log(` ${file.padEnd(17)} downloaded (${chars.size} distinct character${chars.size === 1 ? "" : "s"})`); } const notDownloaded = subsets.filter((s) => !needed.has(s.file)).map((s) => s.file); console.log(` not downloaded: ${notDownloaded.join(", ") || "(none)"}`); console.log(""); } console.log("--- font-display: block and swap periods ---"); const BEHAVIOR = { "auto": { blockPeriod: "implementation-dependent", swapPeriod: "implementation-dependent" }, "block": { blockPeriod: "short (~3s)", swapPeriod: "infinite" }, "swap": { blockPeriod: "very short (~100ms)", swapPeriod: "infinite" }, "fallback": { blockPeriod: "very short (~100ms)", swapPeriod: "short (~3s)" }, "optional": { blockPeriod: "very short (~100ms)", swapPeriod: "none" }, }; for (const [value, periods] of Object.entries(BEHAVIOR)) { console.log(`${value.padEnd(9)} block=${periods.blockPeriod.padEnd(22)} swap=${periods.swapPeriod}`); } console.log("\n--- is text visible during the block period ---"); for (const [value] of Object.entries(BEHAVIOR)) { const visible = value === "block" || value === "auto" ? "no (invisible text)" : "yes (fallback font)"; console.log(`${value.padEnd(9)} -> ${visible}`); }
text: Kuzey Yamac Olcum Istasyonu latin.woff2 downloaded (17 distinct characters) not downloaded: latin-ext.woff2, greek.woff2, cyrillic.woff2 text: Kuzey Yamaç Ölçüm İstasyonu latin.woff2 downloaded (17 distinct characters) latin-ext.woff2 downloaded (1 distinct character) not downloaded: greek.woff2, cyrillic.woff2 text: Sıcaklık: -3,4 °C — bağıl nem %78 latin.woff2 downloaded (22 distinct characters) latin-ext.woff2 downloaded (1 distinct character) not downloaded: greek.woff2, cyrillic.woff2 --- font-display: block and swap periods --- auto block=implementation-dependent swap=implementation-dependent block block=short (~3s) swap=infinite swap block=very short (~100ms) swap=infinite fallback block=very short (~100ms) swap=short (~3s) optional block=very short (~100ms) swap=none --- is text visible during the block period --- auto -> no (invisible text) block -> no (invisible text) swap -> yes (fallback font) fallback -> yes (fallback font) optional -> yes (fallback font)
The first text carries only ASCII characters, and a single file gets downloaded. The second text is in Turkish spelling, and one more file gets added — because of a single character, no less.
That character is the capital dotted İ (U+0130), and it falls into the extended Latin subset. The lowercase dotless ı (U+0131), though, is defined within the base subset. The two letters, despite being part of the same alphabet, come from two separate files. In the third text, the same situation occurs for ğ (U+011F).
This means that in Turkish text, two files get downloaded at once; a heading waits for the second file just because it carries an İ. This cost disappears once subset files get generated to match the text’s own content.
unicode-range works with code points, and the distinction between a
character set and a code point, defined in the How Computers Work
curriculum, applies directly here: the ranges are split by code point
number, not by how the characters look.
Display Behavior
What happens to the text while the font file downloads gets chosen
with font-display. The decision is defined by two periods:
The block period — while the file is awaited, the text does not get drawn. The text is invisible during this period.
The swap period — the text gets drawn with a fallback font; if the file arrives within this period, the font gets swapped.
The output’s second and third blocks distinguish the five values by these two periods. The period values are given in the specification as a recommendation; the exact numbers are implementation-dependent.
block — grants a long block period. The result is that the text
stays invisible for a while. This is an undesired behavior except in
cases like icon fonts, where a fallback is meaningless.
swap — the block period is very short; the text appears immediately
with a fallback font and switches once the file arrives. The text
never becomes invisible, but the layout shifts at the moment of the
switch.
fallback — grants a short block period and a short swap period. If
the file arrives late, the fallback font becomes permanent, and the
layout never shifts again.
optional — has no swap period. If the file does not arrive very
fast, it does not get used at all on that page load; it gets cached
and used on the next visit.
The choice is a trade-off: swap prioritizes the text’s legibility
and accepts layout shift; optional prioritizes layout stability and
accepts giving up on the intended font.
Reducing Layout Shift
If the fallback font’s metrics differ from the actual font’s, the text box grows or shrinks at the moment of the switch, and everything below it shifts. This shift gets reduced in two ways.
The first is bringing the fallback font’s metrics closer to the
actual font’s. The size-adjust, ascent-override,
descent-override, and line-gap-override definitions make it
possible to produce a family from a local font to get used as the
fallback:
@font-face { font-family: "Station Sans Fallback"; src: local("Arial"); size-adjust: 97%; ascent-override: 90%; }
The second is making sure the file gets requested early, while the document gets parsed:
<link rel="preload" href="/fonts/station-sans.woff2" as="font" type="font/woff2" crossorigin>
This link is not a style sheet link; it only declares that the
resource should get requested early. Font requests are subject to
cross-origin rules even when the resource comes from the same origin;
crossorigin therefore has to get written, or the resource gets
downloaded twice.
Local Hosting
The difference between serving a font file from your own server and calling it from a third-party server is not a performance detail.
A third-party resource requires a separate connection to get established: name resolution, connection setup, and the secure handshake all get done again. Each of these steps, defined in the How the Internet Works curriculum, adds latency.
A second consequence concerns privacy: every request tells the third party the visitor’s address and which page they are viewing. Local hosting eliminates this disclosure.
/* station.css — step 19: font definition */ @font-face { font-family: "Station Sans"; src: url("/fonts/station-sans-latin.woff2") format("woff2"); unicode-range: U+0000-00FF, U+0131, U+2000-206F; font-weight: 400; font-display: swap; } @font-face { font-family: "Station Sans"; src: url("/fonts/station-sans-latin-ext.woff2") format("woff2"); unicode-range: U+0100-024F, U+1E00-1EFF; font-weight: 400; font-display: swap; } body { font-family: "Station Sans", system-ui, sans-serif; }
The two definitions share the same family name and get distinguished by their ranges. Because the page has Turkish text, both get downloaded; in an English version only the first one would download.
Summary
@font-faceis not a rule, it is a definition: it ties a family name to a file and declares which weight and style that file provides.unicode-rangedeclares the code points a file covers; if the document has no character from that range, the file never gets downloaded.- In Turkish text, letters like the capital dotted İ and ğ fall into the extended Latin subset; a single character can trigger the download of a second file.
font-displaysets two periods: during the block period the text does not get drawn, during the swap period it gets drawn with a fallback font and gets swapped if the file arrives.- Local hosting eliminates the need for a separate connection setup and prevents visitor information from getting disclosed to a third party.
Next Step
The font has arrived and the size scale is built. Decisions on the text itself remain: how lines get aligned, how whitespace gets processed, letters’ uppercase–lowercase conversion, and how underlined text gets formatted. The next lesson takes up these decisions and shows the whitespace-processing rules with a runnable example.
To keep your progress and take notes, Log in
My notes
Log in to take notes.