CSS Web Fonts (@font-face and Google Fonts)

By default, browsers can only render the small set of fonts installed on a visitor’s device — usually Arial, Times New Roman, and a handful of system fonts. Web fonts remove that limitation: they let you package a custom typeface as a file and have the browser download it, so every visitor sees the exact design you intended. The two main ways to do this are the @font-face at-rule, which loads a font file you control, and third-party services like Google Fonts, which host font files for you and give you a ready-made stylesheet.

This lesson covers both approaches in depth: how @font-face actually works, how to write it correctly, how Google Fonts uses the same mechanism under the hood, and the performance and rendering pitfalls that trip up almost everyone the first time.

Overview / How it works

The @font-face rule doesn’t style an element directly. Instead, it registers a font family name and tells the browser where to find the actual font data for it. Once registered, you use that name anywhere you’d use any other value in the font-family property — on body, a heading, a button, whatever selector you like.

Here is the sequence of events when a page loads and uses a web font:

  • The browser parses your CSS and encounters an @font-face block. It notes the family name and source URL(s), but it does not download anything yet — fonts are lazy-loaded.
  • The browser builds the render tree and figures out which elements actually need that font family (because their computed font-family matches).
  • Only then does it fetch the font file over the network. This is why a huge @font-face declaration with a font nobody uses costs nothing — unused registrations are never downloaded.
  • While the file is downloading, the browser has to decide what to do with the text that’s waiting to be painted. This behavior is controlled by the font-display property, covered below, and historically caused the “flash of invisible text” (FOIT) or “flash of unstyled text” (FOUT) that made early web fonts feel janky.
  • Once the file arrives, the browser swaps in the real glyphs (or repaints with them, depending on font-display), triggering a reflow if the new font has different metrics than the fallback.

Google Fonts, Adobe Fonts, and similar services are simply a hosted @font-face stylesheet plus a CDN full of font files. When you add a Google Fonts <link> tag (or the equivalent @import), you are pulling in a small CSS file that contains @font-face rules pointing at Google’s servers — there is no special magic beyond what you can write yourself.

Syntax

@font-face {
  font-family: <custom name>;
  src: <url()/local()  list>;
  font-weight: <weight or range>;
  font-style: <normal | italic | oblique>;
  font-display: <auto | block | swap | fallback | optional>;
}
Descriptor Purpose
font-family The name you’ll reference later in font-family: .... Can be any string; quote it if it contains spaces.
src A comma-separated list of sources the browser tries in order. Each entry is url(path) format("type") or local("Font Name") to check for an already-installed copy first.
font-weight Which weight this file represents (e.g. 400) or a range like 100 900 for variable fonts.
font-style Whether this file is the normal or italic variant.
font-display Controls the fallback/swap timing while the font downloads (explained below).
unicode-range Optional: restricts the file to specific Unicode code points, so the browser only downloads it if the page actually contains those characters.

Examples

Example 1: A basic self-hosted @font-face

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

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

Result: Every element inheriting body‘s font (which is almost the whole page, since font-family inherits) renders in the Inter typeface once the file downloads. The browser tries the .woff2 file first since it’s listed first and is smaller/more efficient; if that format isn’t supported it falls back to .woff. Text is visible in the fallback system-ui font immediately, then swaps to Inter once it loads, because of font-display: swap.

This is explaining that src is a preference-ordered list, not a set of alternatives the browser merges — it stops at the first format it can use. Listing modern formats first (like woff2) and older ones after is the standard pattern.

Example 2: Multiple weights and an italic style

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

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

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

h1 {
  font-family: "Inter", sans-serif;
  font-weight: 700;
}

em {
  font-family: "Inter", sans-serif;
  font-style: italic;
}

Result: h1 elements render in the bold Inter file (because font-weight: 700 matches the second @font-face block), and text inside em renders in the true italic Inter file rather than a synthetically slanted version. Three separate files are registered under the same family name, and the browser picks whichever one matches the requested weight/style combination for a given element.

Notice all three blocks share font-family: "Inter". That’s intentional and required — it’s how one logical font family can have distinct files per weight and style, instead of you inventing names like Inter-Bold and setting font-family to a different string for bold text.

Example 3: Loading Google Fonts and a variable font

@import url("https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap");

body {
  font-family: "Roboto", Arial, sans-serif;
}

@font-face {
  font-family: "Recursive";
  src: url("/fonts/recursive-variable.woff2") format("woff2-variations");
  font-weight: 300 900;
  font-display: swap;
}

.hero-title {
  font-family: "Recursive", sans-serif;
  font-weight: 650;
}

Result: The @import pulls in Google’s own generated stylesheet, which contains the actual @font-face rules for Roboto at weights 400 and 700; body text renders in Roboto once it loads. Separately, .hero-title uses a self-hosted variable font, and because the registered font-weight is a range (300 900), the browser can render the exact weight 650 from a single file instead of needing a separate file per weight — a major benefit of variable fonts.

In production, Google Fonts is more commonly loaded via a <link rel="stylesheet"> tag in the document <head> rather than @import, because @import blocks the browser from discovering the font request until after the importing stylesheet itself has been fully downloaded and parsed, adding an extra network round trip.

How it works step by step / Under the hood

Understanding font-display is the key to understanding perceived performance with web fonts. When a font is requested, the browser enters a timeline with up to three phases:

  • Block period: if the font hasn’t loaded yet, text using it is invisible (not “unstyled” — literally not painted).
  • Swap period: the browser paints using a fallback font, ready to swap in the real font the instant it arrives.
  • Failure period: after this, the browser gives up waiting and permanently uses the fallback.

Each value of font-display configures the length of these periods differently: auto lets the browser decide (historically similar to block); block gives a short invisible period then swaps, risking FOIT; swap has virtually no block period, so fallback text shows immediately and swaps whenever the font arrives, risking layout shift but never invisible text; fallback gives a very short block period and a short swap period, then commits to the fallback if the font is slow; optional is the most conservative — it gives the browser permission to skip the font entirely if it doesn’t already have it cached, which is ideal for repeat visitors but means first-time visitors might never see the custom font on that page load.

Because a font swap can change character widths, swapping in a much wider or narrower font can trigger a visible reflow (text reflowing to new line breaks). Pairing your custom font with a fallback font of similar x-height and character width, or using the CSS size-adjust descriptor inside @font-face, reduces this layout shift.

Common Mistakes

Mistake 1 — forgetting font-display, causing invisible text:

@font-face {
  font-family: "Brand Sans"
  src: url("/fonts/brand.woff2") format("woff2")
}

This is also missing semicolons after the font-family and src values, which is invalid CSS and will cause the declaration to be parsed incorrectly or dropped entirely. The corrected version:

@font-face {
  font-family: "Brand Sans";
  src: url("/fonts/brand.woff2") format("woff2");
  font-display: swap;
}

Mistake 2 — using font-weight: bold without registering a bold file:

@font-face {
  font-family: "Brand Sans";
  src: url("/fonts/brand-regular.woff2") format("woff2");
  font-weight: 400;
}

strong {
  font-family: "Brand Sans", sans-serif;
  font-weight: 700;
}

This is syntactically valid, but since no @font-face block registers weight 700 for “Brand Sans”, the browser has to synthesize a fake bold by algorithmically thickening the regular glyphs. The result usually looks blurry or uneven compared to a real bold font file. The fix is to add a second @font-face block with font-weight: 700 pointing at an actual bold file, exactly as shown in Example 2 above.

Best Practices

  • Always set font-display: swap (or optional for non-critical decorative text) so users see readable text immediately instead of an invisible flash.
  • Serve woff2 as your primary format — it has the best compression of the widely supported formats — and list it first in src.
  • Only load the weights and styles you actually use. Every extra @font-face weight that a page references is another file download.
  • Prefer self-hosting fonts (including downloading Google Fonts files yourself) over third-party <link>/@import requests when you control the deployment — it avoids an extra DNS lookup/connection and keeps you off another company’s uptime.
  • Use <link rel="preconnect"> to a font CDN’s origin, and consider <link rel="preload"> for your most critical font file, so the download starts earlier.
  • List a reasonable system font fallback (sans-serif, system-ui, etc.) after every custom family in font-family, and choose one with similar proportions to reduce layout shift on swap.
  • Reach for a variable font when you need several weights of the same family — one file replacing four or five separate weight files is a real payload win.

Practice Exercises

Exercise 1: Write an @font-face rule that registers a font named “Editorial Serif” from a single .woff2 file, with font-display: swap, and apply it to all <p> elements with a fallback of serif.

Exercise 2: Register the same family twice — once for font-weight: 400 and once for font-weight: 600 using two different files — then write a rule so that <strong> elements use the 600 weight file instead of a synthesized bold.

Exercise 3: A page currently loads a custom font with no font-display set, and users on slow connections report seeing blank paragraphs for a second before text appears. Rewrite the @font-face rule to fix this, and explain in a comment which font-display value you chose and why.

Summary

  • @font-face registers a custom font family name and points it at one or more font files; it doesn’t style anything by itself until you use that name in a font-family value.
  • Fonts are downloaded lazily — only if a matching font-family is actually used on the page.
  • Multiple @font-face blocks can share one family name, differentiated by font-weight and font-style, so the browser picks the correct file automatically.
  • font-display controls whether text is invisible, shown in a fallback, or delayed while the real font downloads — swap is the safest general default.
  • Google Fonts and similar services are just a hosted @font-face stylesheet — understanding @font-face means you understand what those services do under the hood.
  • Variable fonts let one file cover a whole weight range, reducing the number of files you need to load.