@font-face, descriptor by descriptor

Last updated 2026-11-02

On this page

@font-face does one thing: it gives a font file a family name so font-family can refer to it. It does not load anything by itself and does not transform the font — it registers it.

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

Descriptors are declarations

This is the part that trips people up:

  • font-weight: 400 inside @font-face means "this file is 400", not "render it at 400";
  • to make text bold you write font-weight: 700 in a normal rule, and the browser comes back looking for the face that declares itself 700.

So "I set font-weight: bold and nothing got bolder" usually means there is no 700 face declared, and the browser had to synthesise one (see below).

src and format

src is a candidate list; the browser takes the first it can use:

src: url('/fonts/inter.woff2') format('woff2'),
     url('/fonts/inter.woff') format('woff');
  • Order: newer formats first, older as fallback;
  • format() lets the browser skip unsupported formats instead of downloading them for nothing;
  • every current browser supports woff2; woff is for very old ones, and ttf / otf are large to ship to the web.

local() — useful, with traps

src: local('Inter'), url('/fonts/inter.woff2') format('woff2');

local() tries the installed font first, skipping a download.

The trap is the name: the argument may be a full name or a PostScript name, and browsers do not agree on which is more reliable. The installed copy may also be older than the one you want — or another font claiming the same name. Where rendering must be consistent, skip local() and go straight to url().

One family, several faces

Declare multiple entries under the same name, distinguished by font-weight / font-style:

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

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

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

Now font-family: Inter; font-weight: 700 hits the second entry.

A variable font needs only one rule, with the range in the descriptor:

@font-face {
  font-family: 'InterVariable';
  src: url('/fonts/inter-var.woff2') format('woff2');
  font-weight: 100 900;
  font-display: swap;
}

unicode-range: subsetting

@font-face {
  font-family: 'Noto Sans SC';
  src: url('/fonts/noto-sc-000.woff2') format('woff2');
  unicode-range: U+4E00-9FFF, U+3000-303F;
}

The browser downloads this file only when text falls inside the range. CJK fonts are megabytes; sliced by unicode-range, a page downloads only the slices it uses. The cost is many files and more configuration.

font-display (in one line)

block / swap / fallback / optional decide what is shown during the few hundred milliseconds before the font arrives. It is a main cause of text jumping, and is covered in the loading article.

This site's position

This site does not host font files (see docs/adr/0001): the server stores fingerprints and metadata, never bytes. So there is no real src URL to copy from here — the paths above are placeholders.

The inspector can "temporarily load" a library font by fetching its bytes from the upstream CDN into browser memory and registering them there; nothing lands in this site's storage.

Further reading

  • MDN: @font-face — one page per descriptor
  • CSS Fonts: the @font-face rule and descriptor definitions