Type something to search...
How to use the @font-face rule?

How to use the @font-face rule?

To use the @font-face rule, declare it in your CSS with at least two descriptors: font-family, which gives the font a name you choose, and src, which points to the font file. Once declared, you use that name in a normal font-family property, followed by fallback fonts, and the browser downloads the file only when text on the page actually needs it. You add one @font-face block for each weight and style you want to use, or a single block with a weight range for a variable font. In practice you should also set font-display: swap, serve WOFF2 files and give each block the correct font-weight and font-style so the browser picks the right file.

@font-face is the foundation of every custom web font, including those served by Google Fonts and other services, which simply generate these rules for you. Writing them yourself gives you full control over which files load and how. In this article you'll learn the syntax and every useful descriptor, how to set up a family with several weights, how to declare variable fonts, how to split files with unicode-range, how to control loading behaviour and fallback metrics, and how to troubleshoot fonts that don't appear.

The Basic Syntax

A minimal declaration has a name and a source:

@font-face {
  font-family: "Source Serif";
  src: url("/fonts/source-serif-regular.woff2") format("woff2");
}

body {
  font-family: "Source Serif", Georgia, serif;
}

The font-family inside @font-face is a label you invent. It doesn't need to match the font's internal name, although keeping them similar avoids confusion. The body rule then uses that label, with Georgia and the generic serif family as fallbacks if the file fails to load.

Two details are worth getting right from the start:

  • The URL is relative to the stylesheet: if your CSS lives at /css/main.css and you write url("fonts/a.woff2"), the browser looks in /css/fonts/. A leading slash makes the path relative to the site root, which is usually clearer.
  • Quotes around the family name: they're required if the name contains anything other than letters, digits and hyphens, and are a good habit regardless.

Every Descriptor You'll Use

These are the descriptors defined by the CSS Fonts specification that matter in everyday work:

DescriptorPurposeExample
font-familyThe name you'll refer to"Inter"
srcWhere to find the file, with optional formaturl("/f/inter.woff2") format("woff2")
font-weightThe weight, or range of weights, the file covers400 or 100 900
font-styleWhether the file is upright or italicnormal or italic
font-stretchThe width, or range of widths, it covers75% 125%
font-displayHow text behaves while the font loadsswap
unicode-rangeWhich characters the file containsU+0000-00FF
size-adjustScales the glyphs, mainly for fallbacks105%
ascent-override / descent-override / line-gap-overrideOverride vertical metrics90%

Descriptors in @font-face describe the file. They don't style any text. When you write font-weight: 700 inside the block, you're telling the browser "this file is the bold one", not making anything bold.

The src Descriptor in Detail

src takes a comma-separated list of sources. The browser uses the first one it can load and support:

@font-face {
  font-family: "Brand Sans";
  src:
    local("Brand Sans"),
    url("/fonts/brand-sans.woff2") format("woff2"),
    url("/fonts/brand-sans.woff") format("woff");
}
  • local() tells the browser to use an installed copy if one exists. It's less useful than it sounds: the user's version may differ from yours, and some browsers restrict local() matching for privacy reasons. Many teams leave it out for predictability.
  • url() points to a file you host.
  • format() is a hint that lets the browser skip formats it doesn't support without downloading them.

Every current browser supports WOFF2, so a single WOFF2 source is enough for most sites today. A WOFF fallback only helps very old browsers.

Declaring a Family with Several Weights

With static fonts, each weight and style is a separate file and needs its own block. The key is to use the same font-family name in all of them and set font-weight and font-style correctly:

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

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

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

Now normal CSS just works:

body {
  font-family: "Lora", Georgia, serif;
}

strong {
  font-weight: 700;  /* uses lora-700.woff2 */
}

em {
  font-style: italic;  /* uses lora-400-italic.woff2 */
}

Avoid Separate Family Names per Weight

A common mistake is naming each file as its own family, such as "Lora Bold" and "Lora Italic". That breaks the link between the files. A strong element inside text set in "Lora" won't find a bold file, so the browser creates a synthetic bold by smearing the regular glyphs, which looks noticeably worse. Keep one family name and let the descriptors do the matching.

Faux Bold and Italic

If you use a weight or style you haven't declared, browsers synthesise it. You can switch that off so missing styles are obvious during development:

body {
  font-synthesis: none;
}

With font-synthesis: none, an undeclared bold will display at regular weight instead of a fake bold, which makes the gap easy to spot.

Declaring Variable Fonts

A variable font holds a whole range of weights, and sometimes widths or other axes, in one file. You declare the range rather than a single value:

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

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

Any font-weight from 100 to 900, including in-between values like 450 or 650, now renders from the same file. If the font also has a width axis, add font-stretch: 75% 125% or whatever range the font supports.

You may see format("woff2-variations") in older tutorials. Modern browsers recognise format("woff2") for variable files, and the CSS Fonts Level 4 specification also defines format("woff2") tech(variations). Plain format("woff2") is the simplest option that works everywhere current.

Splitting Files with unicode-range

unicode-range tells the browser which characters a file contains. If no text on the page uses those characters, the file isn't downloaded. This is how Google Fonts serves separate Latin, Latin Extended, Cyrillic and Greek files under one family name.

/* Basic Latin and common punctuation */
@font-face {
  font-family: "Noto Sans";
  src: url("/fonts/noto-sans-latin.woff2") format("woff2");
  font-weight: 400;
  font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/* Cyrillic */
@font-face {
  font-family: "Noto Sans";
  src: url("/fonts/noto-sans-cyrillic.woff2") format("woff2");
  font-weight: 400;
  font-display: swap;
  unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}

An English page downloads only the Latin file. A page with Russian text downloads both. To produce the smaller files in the first place, you need to subset the font; tools such as pyftsubset from the fontTools library do this, and the process is covered in the post on font subsetting.

Controlling Loading with font-display

font-display decides what visitors see while the font downloads:

  • swap: show fallback text immediately, swap to the web font when it arrives. Best for body text.
  • fallback: hide text very briefly (about 100ms), show the fallback, and only swap if the font arrives within roughly three seconds.
  • optional: hide text very briefly, then use the font only if it's already available; otherwise stick with the fallback for that page view.
  • block: hide text for up to about three seconds while waiting. Rarely a good idea.
  • auto: let the browser decide, which in practice usually behaves like block.

For most sites, swap is the right default. If layout shift from the swap is a concern, optional avoids it entirely at the cost of sometimes showing the fallback.

Matching Fallback Metrics

The swap from fallback to web font can shift text, because the two fonts have different widths and heights. You can reduce that by creating a second @font-face that wraps a local system font and adjusts its metrics to match:

@font-face {
  font-family: "Lora Fallback";
  src: local("Georgia");
  size-adjust: 104%;
  ascent-override: 92%;
  descent-override: 25%;
  line-gap-override: 0%;
}

body {
  font-family: "Lora", "Lora Fallback", Georgia, serif;
}

The values above are illustrative. Work out the real numbers for your font pair by comparing their metrics, which tools such as the Fontaine and Capsize projects can calculate for you. size-adjust and the override descriptors are supported in Chrome, Edge and Firefox; Safari supports size-adjust but its support for the override descriptors has lagged, so check current compatibility data before relying on them.

Preloading the Critical File

Browsers don't discover font files until they've built the CSS and found text that needs them, which can delay the download. For the one or two files used above the fold, a preload hint in the head starts the download early:

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

The crossorigin attribute is required even for fonts on your own domain, because fonts are always fetched in CORS mode. Without it, the browser downloads the file twice. Preload only what you're sure every page needs, or you'll waste bandwidth.

Troubleshooting Fonts That Don't Load

When a declared font doesn't show up, work through these checks:

  1. Open the Network panel: filter by Font and reload. If the file isn't listed, no text is using the family name, or the name doesn't match exactly.
  2. Check for 404s: a wrong path is the most common cause. Remember URLs are relative to the CSS file.
  3. Check for CORS errors: fonts loaded from another domain need an Access-Control-Allow-Origin header on the response. The Console will show an error if it's missing.
  4. Check the MIME type: servers should send WOFF2 files as font/woff2. Some misconfigured servers send them as text/html or application/octet-stream.
  5. Check the rendered font: in Chrome or Edge DevTools, select an element, open the Computed tab and scroll to Rendered Fonts. Firefox has a dedicated Fonts panel. This shows which font actually drew the text.

You can also check loading state from JavaScript using the CSS Font Loading API:

document.fonts.ready.then(() => {
  for (const face of document.fonts) {
    console.log(face.family, face.weight, face.style, face.status);
  }
});
"Lora" 400 normal loaded
"Lora" 400 italic unloaded
"Lora" 700 normal loaded

A status of unloaded simply means nothing on the page has needed that face yet. An error status points to a problem with the file or its URL.


FAQ: The @font-face Rule

Put it near the top of your main stylesheet, before the rules that use the font. It can live in any stylesheet the page loads, but keeping all font declarations together makes them easier to maintain.

With static fonts, yes: one block per weight and style, all sharing the same family name. With a variable font, one block with a weight range such as 100 900 covers every weight, plus a second block if there's a separate italic file.

WOFF2 alone is enough for all current browsers. Add WOFF only if you need to support very old browsers. TTF and OTF files are larger and don't need to be served on the web.

The most common causes are a wrong file path, a family name that doesn't match exactly, a missing CORS header for fonts on another domain, or a server sending the wrong MIME type. Check the Network panel and Console first.

No. The browser only downloads a face when text on the page uses that family, weight and style. Preloading is how you ask it to start earlier for critical fonts.

Yes. Google Fonts are released under open licences such as the SIL Open Font License, so you can download the files, host them yourself and write your own @font-face rules for them.


Conclusion

The @font-face rule connects a name you choose to a font file, and tells the browser which weight, style and characters that file covers. With a correct src, matching font-weight and font-style descriptors and one shared family name, normal CSS like strong and em picks the right files automatically, and variable fonts reduce a whole family to one or two declarations.

Add font-display: swap, serve WOFF2, preload only the critical file and consider a metric-adjusted fallback to reduce layout shift. When something goes wrong, the Network panel, the Console and the rendered font information in DevTools will almost always tell you why. With those habits, custom fonts become a predictable part of your build rather than a source of surprises.

Share :

Related Posts

How to make typography accessible?

How to make typography accessible?

You make typography accessible by choosing clear typefaces, setting text in relative units so it scales with user preferences, giving it enough colou

Dive Deeper
What are the parts of a letterform?

What are the parts of a letterform?

A letterform is the shape of a single letter, and typographers break it down into named parts. The main ones are the stem (the main vertical

Dive Deeper
What are ascenders, descenders and the baseline?

What are ascenders, descenders and the baseline?

The baseline is the invisible line that letters sit on. Ascenders are the parts of lowercase letters that rise above the x-height, such as th

Dive Deeper