Command Palette

Search for a command to run...

Hectal
PHASE 4Beginner ~27 min· topic 2 of 6

Topic 4.2

Web Fonts

In one line

@font-face lets a page download its own font files so every visitor sees the same typeface; font-display, the WOFF2 format, subsetting and preloading keep that download from making text invisible or slow.

Think of it like this

Bringing your own pens to an exam. The school (the visitor's device) has some pens, but you want your favourite one, so you carry it in your bag. A web font is a font file the page brings along: the browser downloads it from your server, so the text looks the same on every device. Carrying it costs a little effort (download time), so you pack light.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Web font
A font file the page downloads, instead of relying on fonts installed on the device.
@font-face
The CSS at-rule that declares a web font: its name, its file and which weight/style it is.
At-rule
A CSS statement starting with @, like @font-face or @media, that gives the browser an instruction.
WOFF2
The compressed font file format made for the web. Use it for every web font today.
font-display
A descriptor that decides what text looks like while the font file is still downloading.
FOIT
Flash of invisible text: text is hidden until the font arrives.
FOUT
Flash of unstyled text: text shows in a fallback font first, then changes to the web font.
Subsetting
Removing characters you don't need from a font file to make it smaller.
Variable font
One font file that contains a whole range of weights (and sometimes widths) instead of one file per weight.
Preload
A <link rel="preload"> hint telling the browser to start downloading a file early.

Step by step

01Put the font file on your site

Download the font in WOFF2 format (for example from Google Fonts or the font's own site) and put it in a folder such as /fonts. Check the licence allows web use.

02Declare it with @font-face

Give it a name and point at the file. The name is yours to choose; it only has to match what you write in font-family later.

style.csswhole filecss
@font-face {
  font-family: "Inter";
  src: url("/fonts/inter-regular.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

03Add the bold face with the same name

A second rule with the same family name and font-weight: 700 tells the browser which file to use for bold text. Without it, the browser fakes bold by thickening the regular letters, which looks smudgy.

style.csswhole filecss
@font-face {
  font-family: "Inter";
  src: url("/fonts/inter-bold.woff2") format("woff2");
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}

04Use it in a stack

Use the name like any font, with fallbacks. While the file downloads (and if it fails), the fallbacks are used.

style.csswhole filecss
body {
  font-family: "Inter", system-ui, sans-serif;
}

05Understand the loading timeline

The browser finds the font is needed only after it has the HTML and CSS and knows some text uses it. Then it downloads the file. font-display decides what happens in the meantime.

Understand the loading timelinediagram
Rendering diagram…

06Preload the most important file

A preload in the HTML head starts the download before the CSS is even read. crossorigin is required for fonts, even on your own domain, because fonts are always fetched in CORS mode; without it the preloaded file isn't reused and downloads twice.

index.htmlwhole filehtml
<link rel="preload" href="/fonts/inter-regular.woff2" as="font" type="font/woff2" crossorigin>

Try it yourself

  1. 1

    Change the font-display value

    In the first example, change swap to optional and run. Predict the second console line before you look. The CSSOM reports exactly what you wrote.

  2. 2

    Point at a font you don't have

    In the first example, change both local(...) names to local("Not A Real Font"). Predict what the heading looks like. Nothing in src loads, so the browser moves on to the next font in the stack: serif.

  3. 3

    Watch real fonts load

    Open any site that uses web fonts, open DevTools → Network, filter by Font, and reload. You'll see each .woff2 file, its size and when it started. Then set throttling to Slow 4G and reload to see the FOUT or FOIT with your own eyes.

Code & diagrams

@font-face with a local source New tab

In real projects `src` points at a `.woff2` file with `url()`. The preview can't load files, so this uses `local()`. Rename the family in both places and it still works: the name is yours to choose.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Found a CSSFontFaceRule
font-display: swap
Fonts are only used when text needs them New tab

`document.fonts` is the page's list of declared faces. With `url()` sources, only faces that some text uses are downloaded; "Unused Font" would never be fetched.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Faces declared: 2
Shown Font is used by .used: true
Google Fonts: the two common wayshtml
<!-- 1. Link Google's stylesheet (quick to set up) -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap">

<!-- 2. Self-host: download the .woff2 files, then write your own @font-face -->
<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin>
<style>
  @font-face {
    font-family: "Inter";
    src: url("/fonts/inter-var.woff2") format("woff2");
    font-weight: 100 900;   /* a variable font: every weight in one file */
    font-display: swap;
  }
  body { font-family: "Inter", system-ui, sans-serif; }
</style>
Splitting a font by language with unicode-rangecss
/* Latin letters: downloaded for almost every page */
@font-face {
  font-family: "Noto Sans";
  src: url("/fonts/noto-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153;
  font-display: swap;
}
/* Devanagari (Hindi): downloaded only if the page contains Hindi text */
@font-face {
  font-family: "Noto Sans";
  src: url("/fonts/noto-devanagari.woff2") format("woff2");
  unicode-range: U+0900-097F;
  font-display: swap;
}

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Different names in @font-face and font-family

Declare font-family: "Inter"; in @font-face, but write font-family: "Inter Regular", sans-serif; on body.

terminal
$ the page shows
── what you'll see ──
Text in the fallback sans-serif font. The Network panel shows no request for the .woff2 file.

Break #2

Preload without crossorigin

Write <link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2"> with no crossorigin attribute.

terminal
$ Console
── what you'll see ──
A preload for 'https://example.com/fonts/inter.woff2' is found, but is not used because the request credentials mode does not match. Consider taking a look at crossorigin attribute.
(and the Network panel shows the font downloaded twice)

Break #3

Load every weight "just in case"

Link a Google Fonts URL with nine weights and their italics, but use only regular and bold on the page.

terminal
$ Lighthouse / PageSpeed
── what you'll see ──
Large CSS for the fonts, slower first paint, and text that swaps fonts late on slow connections.

Myth vs fact

Myth

Declaring a web font downloads it immediately.

Fact

Browsers download a face only when some text on the page needs it. Unused @font-face rules cost nothing but the CSS bytes. Preloading is how you start a download early on purpose.

Myth

font-display: swap is always best.

Fact

swap keeps text visible but can cause a visible jump (layout shift) when the font arrives late. optional avoids the jump and suits body text on slow networks; swap suits brand headings that must use the font.

Myth

Google Fonts is always faster because it's on a CDN.

Fact

Browsers partition their cache by site since about 2020, so another site's cached Google Fonts file isn't reused on yours. Self-hosting removes the extra connection to another domain and is often faster.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Font swaps cause Cumulative Layout Shift (a Core Web Vitals metric) when the fallback and the web font have different metrics. The @font-face descriptors size-adjust, ascent-override, descent-override and line-gap-override let you define a tuned fallback face (for example src: local("Arial") with size-adjust: 107%) that occupies almost the same space as the web font.

  • ▸

    The font-display periods come from the CSS Fonts Level 4 spec: a block period (text drawn invisible), then a swap period (fallback shown, swap allowed), then a failure period (stay on the fallback). block ≈ 3s block, infinite swap; swap ≈ 0 block, infinite swap; fallback ≈ 100ms block, ~3s swap; optional ≈ 100ms block, no swap.

  • ▸

    The CSS Font Loading API (document.fonts, new FontFace(), document.fonts.load("1em Inter"), document.fonts.ready) lets JavaScript load fonts on demand and wait for them, which is useful before measuring text or drawing on a canvas.

  • ▸

    Fonts from another origin need the server to send Access-Control-Allow-Origin; without it the browser refuses to use the file (a CORS error in the console). See the System Design course for how CDNs and caching headers work: fonts should be served with long Cache-Control lifetimes and versioned file names.

Remember this

  1. 1

    A web font is declared with the @font-face at-rule (a CSS instruction that starts with @). Inside it you give the font a family name of your choice with font-family, and say where the file is with src. After that, you use the name in any normal font stack: font-family: "Inter", system-ui, sans-serif;.

  2. 2

    src can list several sources, tried in order. url("/fonts/inter.woff2") format("woff2") points at a file; local("Inter") uses a copy already installed on the device. WOFF2 (Web Open Font Format 2) is the format to use in 2026: it is compressed with Brotli and supported by every modern browser. WOFF is its older, larger predecessor; raw TTF/OTF files are bigger still; EOT and SVG fonts are obsolete.

  3. 3

    One @font-face rule describes one file, usually one weight and style. To get regular, bold and italic, you write several rules with the same font-family name and different font-weight / font-style descriptors. The browser then picks the right file when text is bold or italic. A variable font packs a whole range of weights into one file: font-weight: 100 900; in the rule.

  4. 4

    Fonts are downloaded lazily: only when the page actually has text that uses that face. Until the file arrives, the browser must decide what to show. **font-display** controls this: block hides text for up to about 3 seconds (a FOIT, flash of invisible text); swap shows a fallback font at once and swaps when the file arrives (a FOUT, flash of unstyled text); fallback hides for about 100 ms, then shows the fallback and swaps only if the font arrives within about 3 seconds; optional hides for about 100 ms and uses the font only if it is already there (often cached), never swapping later. auto lets the browser decide, which is usually like block.

  5. 5

    Google Fonts hosts free fonts and writes the @font-face rules for you. You add a <link> to their stylesheet with &display=swap in the URL and use the family name. It is convenient, but it adds requests to another server (fonts.googleapis.com for the CSS, fonts.gstatic.com for the files). Many teams download the files and self-host them for speed and privacy.

  6. 6

    Performance rules: use WOFF2 only; load as few weights as possible; subset fonts (keep only the characters you need) and use unicode-range so a file is fetched only when those characters appear; preload the one or two most important files with <link rel="preload" as="font" type="font/woff2" crossorigin>. Font licences matter too: only host fonts whose licence allows web use (Google Fonts are open licences).

Explain it without notes

01

How do you add a custom web font to a page, step by step?

02

What do the font-display values block, swap, fallback and optional do, and when would you pick each?

03

Why is WOFF2 the recommended format, and what other ways can you make fonts load faster?

04

Why does a font preload need the crossorigin attribute?

Practice

01

Write @font-face rules for a family called Lato with a regular file /fonts/lato-400.woff2 and a bold file /fonts/lato-700.woff2, both showing fallback text immediately, then use it on body.

02

Write the HTML line that preloads /fonts/lato-400.woff2 correctly.

03

Declare a variable font Recursive from /fonts/recursive.woff2 that covers weights 300 to 1000, with font-display: optional.

Trade-offs

  • ↔

    Web fonts give a consistent brand look but cost download time and can cause invisible text or layout shifts; system fonts cost nothing but vary by device.

  • ↔

    Google Fonts is quick to set up and serves optimised subsets; self-hosting needs a little work but avoids a third-party connection and keeps visitor data on your site.

  • ↔

    A variable font is one request for all weights but may be larger than two static files if you only use two weights.

Done when you can

  • I can write @font-face rules for regular and bold with one family name.

  • I can explain FOIT and FOUT and choose a font-display value.

  • I know why WOFF2 is the format to use.

  • I can preload a font correctly, including crossorigin.

  • I can add a Google Font with display=swap or self-host it.

  • I can list at least four ways to make fonts load faster.