WasiLearn Code
CSS · 2. Colour, text and units

Web fonts: @font-face and fallbacks

Every font in the last lesson had to already be on the reader's machine. A web font is one you ship with the page, so the design looks the same everywhere — at the cost of a file the reader has to download before they can read a word.

That cost is the whole subject. The syntax is five lines; the interesting part is what the page does during the second or two before the font arrives.

@font-face declares a font that does not exist yet

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

body {
  font-family: "Inter", system-ui, sans-serif;
}

@font-face does not apply anything. It defines a name, teaching the browser that "Inter" means "the file at this address". The second rule is what actually uses it, and it still needs a fallback stack, because the file may be slow or may never arrive.

font-family inside the block is a label you choose. You could call it "Body" and it would work, as long as both places agree.

One weight per block

This is the part that surprises people. @font-face maps one file to one weight and one style. Bold is a separate file and a separate block:

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

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

Same font-family name in both. The browser collects them into one family and picks the right file when you write font-weight: 700.

Get this wrong — declare only the regular file and then use font-weight: bold — and the browser does not fail. It synthesises a bold by algorithmically thickening the letterforms, which looks smeared and slightly wrong in a way that is hard to name until you see the real bold beside it.

The same applies to italics. A synthetic italic is the upright font mechanically slanted, and it is obviously not the italic the designer drew.

So: every weight and style you use needs its own file and its own block. Which is also the argument for using two weights rather than five.

Use woff2, and generally only woff2

Fonts come in several formats, and you will find long "bulletproof" src stacks online listing four or five of them. Those are relics.

woff2 is compressed specifically for the web, is typically 30% smaller than woff, and is supported by every browser in current use. A single format is the right answer:

src: url("/fonts/inter-regular.woff2") format("woff2");

If you genuinely must support something ancient, add woff as a second entry — the browser takes the first format it understands:

src: url("/fonts/inter-regular.woff2") format("woff2"),
     url("/fonts/inter-regular.woff")  format("woff");

.ttf and .otf are desktop formats. They work, and they are roughly twice the size for no benefit. .eot was for Internet Explorer and is dead.

The flash, and font-display

While a web font downloads, the browser has to show something. What it chooses is controlled by font-display, and the default is not what you want.

Two failure modes have names:

  • FOIT — Flash of Invisible Text. The browser hides the text entirely until the font arrives. The reader sees a blank space where the headline should be.
  • FOUT — Flash of Unstyled Text. The fallback font shows immediately, then the text re-renders in the web font when it loads.

FOUT looks like a glitch. FOIT looks like a broken page, and on a slow connection it can last three seconds. Given the choice, FOUT wins every time, because the reader can read.

The default, font-display: auto, means "browser's choice", and most browsers choose a short invisible period — a small FOIT. So set it explicitly:

font-display: swap;

swap shows the fallback immediately and swaps in the web font whenever it arrives, however long that takes. Text is readable from the first paint.

The alternative worth knowing is optional: the browser gives the font a very short window, and if it misses it, uses the fallback for that page load and does not swap. Nothing ever moves. That is the right choice for body text on a site where the exact typeface is a nicety rather than the brand.

The swap is a layout shift, and you can shrink it

swap trades invisible text for movement: the fallback renders, then the real font replaces it, and if the two fonts have different proportions the text reflows. Headlines re-wrap; everything below jumps.

You reduce that by choosing a fallback whose metrics are close, and by nudging it to match:

@font-face {
  font-family: "Inter Fallback";
  src: local("Arial");
  size-adjust: 107%;
  ascent-override: 90%;
  descent-override: 22%;
}

body {
  font-family: "Inter", "Inter Fallback", sans-serif;
}

local() uses a font already installed rather than downloading anything, and the override descriptors stretch its metrics towards the web font's. Done well, the swap becomes almost invisible.

You do not compute those percentages by hand — tools generate them from the two font files. It is worth knowing the technique exists, and worth ignoring until you have a real font and a real measurement.

preload the one font that matters

A web font is discovered late: the browser must download the HTML, then the CSS, then parse the CSS, and only then does it learn a font file exists. That is three round trips before the download starts.

For the one or two fonts used above the fold, preload starts the fetch immediately:

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

That crossorigin is mandatory and catches everybody. Fonts are always fetched in anonymous CORS mode; without the attribute the preload is treated as a different request from the real one, and the browser downloads the file twice. It is required even for a font on your own domain.

Preload sparingly. Every preload competes with everything else for bandwidth, so preloading five fonts makes the page slower, not faster.

Self-hosting versus a font CDN

Loading a font from someone else's server used to be the obvious choice. Browsers now partition their caches per site, so the old argument — "the visitor probably has it cached from another site" — is simply no longer true. Every site downloads it fresh.

What remains is a third-party connection: an extra DNS lookup and TLS handshake before the download begins, a dependency on somebody else's uptime, and the reader's IP address going to a company they did not choose to visit. That last point is why some jurisdictions treat a hotlinked font as a privacy matter.

Self-hosting is a download and a folder. Put the woff2 files in /fonts/, write the @font-face blocks, and everything comes from one origin. For most sites it is faster, simpler and easier to reason about.

Check the licence before you ship a font. Open-source families are free to self-host; a commercial licence often specifies exactly how it may be served.

Subsetting, if the file is large

A font covering Latin, Cyrillic, Greek and every symbol in Unicode is a large file, and an English-language site uses a fraction of it. unicode-range lets you ship separate slices and lets the browser download only the ones the page actually needs:

@font-face {
  font-family: "Inter";
  src: url("/fonts/inter-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+2018-201A, U+2032-2033;
  font-display: swap;
}

If no character on the page falls in that range, the file is never fetched. Font subsetting tools generate both the sliced files and the ranges.

What this costs, honestly

Two weights of one family is roughly 30–60KB of woff2 — noticeable on a slow connection, and reasonable for a design that depends on it. Five weights of two families is a quarter of a megabyte before a single word appears.

system-ui costs nothing, downloads nothing, cannot flash, cannot shift, and looks native on every platform. It is a genuinely good default, and worth choosing deliberately rather than by omission.

If you do use a web font: one family, two weights, woff2 only, font-display: swap, preload the one used above the fold. That covers almost every case well.

That closes the colour, text and units section. The text styling lesson covers the fallback stacks these rules depend on, and units covers the sizing that goes with them.

Prefer learning with a teacher?

WasiLearn Academy runs small-group classes covering this material, online and in Karachi.

See the classes