Skip to content
academia.sh

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-face is 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-range declares 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-display sets 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.

Start typing to search.

↑↓ Esc navigate · open · close