@font-face & Fonts
Web fonts allow you to use custom typefaces that are not installed on the user's system. The @font-face at-rule defines custom fonts, while properties like font-family, font-weight, and font-display control how they are applied and rendered.
Modern web font technology includes WOFF2 compression, variable fonts (multiple weights/styles in one file), and advanced display strategies to balance performance with visual fidelity. Understanding these tools is essential for building fast, typographically rich web experiences.
| 1 | @font-face { |
| 2 | font-family: "Geist"; |
| 3 | src: url("/fonts/geist.woff2") format("woff2"); |
| 4 | font-weight: 400; |
| 5 | font-style: normal; |
| 6 | font-display: swap; |
| 7 | } |
The @font-face at-rule defines a custom font family name and points to the font file(s). You can declare multiple @font-face blocks for different weights, styles, and unicode ranges under the same family name.
| 1 | /* Basic declaration */ |
| 2 | @font-face { |
| 3 | font-family: "MyFont"; /* custom family name */ |
| 4 | src: url("/fonts/myfont.woff2") format("woff2"), |
| 5 | url("/fonts/myfont.woff") format("woff"); /* fallback */ |
| 6 | font-weight: 400; |
| 7 | font-style: normal; |
| 8 | font-display: swap; |
| 9 | unicode-range: U+0000-00FF; /* basic Latin only */ |
| 10 | } |
| 11 | |
| 12 | /* Multiple weights under same family */ |
| 13 | @font-face { |
| 14 | font-family: "Inter"; |
| 15 | src: url("/fonts/inter-regular.woff2") format("woff2"); |
| 16 | font-weight: 400; |
| 17 | } |
| 18 | |
| 19 | @font-face { |
| 20 | font-family: "Inter"; |
| 21 | src: url("/fonts/inter-bold.woff2") format("woff2"); |
| 22 | font-weight: 700; |
| 23 | } |
| 24 | |
| 25 | /* Italic variant */ |
| 26 | @font-face { |
| 27 | font-family: "Inter"; |
| 28 | src: url("/fonts/inter-italic.woff2") format("woff2"); |
| 29 | font-weight: 400; |
| 30 | font-style: italic; |
| 31 | } |
| 32 | |
| 33 | /* Variable font declaration */ |
| 34 | @font-face { |
| 35 | font-family: "InterVariable"; |
| 36 | src: url("/fonts/inter-variable.woff2") format("woff2"); |
| 37 | font-weight: 100 900; /* range of weights */ |
| 38 | font-style: normal; |
| 39 | } |
info
font-family specifies a prioritized list of font families. The browser uses the first available font. Always end with a generic family keyword (serif, sans-serif, monospace) as a final fallback.
| 1 | /* System font stack — fastest performance */ |
| 2 | body { |
| 3 | font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; |
| 4 | } |
| 5 | |
| 6 | /* Modern sans-serif stack */ |
| 7 | .sans { |
| 8 | font-family: "Inter", "SF Pro", "Helvetica Neue", Arial, sans-serif; |
| 9 | } |
| 10 | |
| 11 | /* Terminal monospace stack */ |
| 12 | code, pre, .terminal { |
| 13 | font-family: "Fira Code", "SF Mono", Menlo, "Cascadia Code", monospace; |
| 14 | } |
| 15 | |
| 16 | /* Serif for body */ |
| 17 | .serif { |
| 18 | font-family: "Merriweather", "Georgia", "Times New Roman", serif; |
| 19 | } |
| 20 | |
| 21 | /* Specific fonts */ |
| 22 | /* system-ui — OS native UI font */ |
| 23 | /* -apple-system — macOS/iOS San Francisco */ |
| 24 | /* Segoe UI — Windows */ |
| 25 | /* Roboto — Android/ChromeOS */ |
font-weight controls text thickness. Keywords map to numeric values: normal=400, bold=700. Variable fonts allow any weight between 1 and 1000.
| 1 | /* Keyword values */ |
| 2 | .normal { font-weight: normal; } /* 400 */ |
| 3 | .bold { font-weight: bold; } /* 700 */ |
| 4 | |
| 5 | /* Numeric values */ |
| 6 | .thin { font-weight: 100; } |
| 7 | .extra-light { font-weight: 200; } |
| 8 | .light { font-weight: 300; } |
| 9 | .regular { font-weight: 400; } |
| 10 | .medium { font-weight: 500; } |
| 11 | .semi-bold { font-weight: 600; } |
| 12 | .bold-num { font-weight: 700; } |
| 13 | .extra-bold { font-weight: 800; } |
| 14 | .black { font-weight: 900; } |
| 15 | |
| 16 | /* Variable fonts — any value */ |
| 17 | .variable-weight { |
| 18 | font-family: "InterVariable", sans-serif; |
| 19 | font-weight: 450; /* between Regular and Medium */ |
| 20 | } |
| 21 | |
| 22 | /* Relative keywords */ |
| 23 | .lighter { font-weight: lighter; } /* lighter than parent */ |
| 24 | .bolder { font-weight: bolder; } /* bolder than parent */ |
| 25 | |
| 26 | /* Terminal use */ |
| 27 | .terminal-title { |
| 28 | font-weight: 600; |
| 29 | color: #E0E0E0; |
| 30 | } |
| 31 | |
| 32 | .terminal-meta { |
| 33 | font-weight: 300; |
| 34 | color: #525252; |
| 35 | } |
font-style controls italic and oblique variants. font-stretch controls condensed/expanded variants (supported mainly by variable fonts).
| 1 | /* font-style */ |
| 2 | .normal { font-style: normal; } |
| 3 | .italic { font-style: italic; } /* uses italic variant */ |
| 4 | .oblique { font-style: oblique; } /* artificially slanted */ |
| 5 | .oblique-deg { font-style: oblique 10deg; } /* with angle */ |
| 6 | |
| 7 | /* font-stretch — condensed to expanded */ |
| 8 | .condensed { font-stretch: condensed; } /* narrower */ |
| 9 | .normal-stretch { font-stretch: normal; } /* regular */ |
| 10 | .expanded-stretch { font-stretch: expanded; } /* wider */ |
| 11 | |
| 12 | /* Percentage values */ |
| 13 | .stretch-50 { font-stretch: 50%; } /* ultra-condensed */ |
| 14 | .stretch-100 { font-stretch: 100%; } /* normal */ |
| 15 | .stretch-200 { font-stretch: 200%; } /* ultra-expanded */ |
| 16 | |
| 17 | /* Keyword values */ |
| 18 | .ultra-condensed { font-stretch: ultra-condensed; } |
| 19 | .extra-condensed { font-stretch: extra-condensed; } |
| 20 | .semi-condensed { font-stretch: semi-condensed; } |
| 21 | .semi-expanded { font-stretch: semi-expanded; } |
| 22 | .extra-expanded { font-stretch: extra-expanded; } |
| 23 | .ultra-expanded { font-stretch: ultra-expanded; } |
| 24 | |
| 25 | /* Variable font axis */ |
| 26 | /* font-stretch maps to 'wdth' axis in variable fonts */ |
font-display controls the font loading behavior and the trade-off between displaying text quickly (with a fallback font) and waiting for the custom font. It addresses the FOIT (Flash of Invisible Text) vs FOUT (Flash of Unstyled Text) problem.
| 1 | /* Font display strategies */ |
| 2 | @font-face { |
| 3 | font-family: "MyFont"; |
| 4 | src: url("myfont.woff2") format("woff2"); |
| 5 | |
| 6 | font-display: auto; /* browser default — usually short block + swap */ |
| 7 | font-display: block; /* hide text for ~3s (block), then swap */ |
| 8 | font-display: swap; /* show fallback immediately, swap when ready */ |
| 9 | font-display: fallback; /* short block (~100ms), then swap */ |
| 10 | font-display: optional; /* extremely short block, may never swap */ |
| 11 | } |
| 12 | |
| 13 | /* Recommended strategy by use case */ |
| 14 | /* Body text → swap (text visible immediately) */ |
| 15 | /* Brand fonts → block (brand typography important) */ |
| 16 | /* Icon fonts → block (invisible icons = broken UI) */ |
| 17 | /* Decorative → optional (nice to have, not essential) */ |
| 18 | /* Performance-critical → optional or fallback */ |
| Value | Block Period | Swap Period | Best For |
|---|---|---|---|
| auto | Varies by browser | Infinite (if loads) | Default |
| block | ~3s | Infinite | Brand/icon fonts |
| swap | 0 | Infinite | Body text (best UX) |
| fallback | ~100ms | ~3s | Balance |
| optional | ~100ms | None | Non-critical fonts |
best practice
Variable fonts (OpenType Font Variations) bundle multiple font instances into a single file. Instead of loading separate files for regular, bold, italic, condensed, etc., one variable font file contains all variations along registered axes (weight, width, slant, optical size) and custom axes.
| 1 | /* Declare a variable font */ |
| 2 | @font-face { |
| 3 | font-family: "InterVariable"; |
| 4 | src: url("/fonts/inter-variable.woff2") format("woff2"); |
| 5 | font-weight: 100 900; /* weight axis range */ |
| 6 | font-stretch: 75% 125%; /* width axis range */ |
| 7 | font-style: normal; |
| 8 | } |
| 9 | |
| 10 | /* Use standard CSS properties */ |
| 11 | .title { |
| 12 | font-family: "InterVariable", sans-serif; |
| 13 | font-weight: 450; /* any value in range */ |
| 14 | font-stretch: 110%; /* slightly wider */ |
| 15 | } |
| 16 | |
| 17 | /* Low-level axis control */ |
| 18 | .creative { |
| 19 | font-family: "InterVariable", sans-serif; |
| 20 | font-variation-settings: |
| 21 | "wght" 450, /* weight */ |
| 22 | "wdth" 110, /* width */ |
| 23 | "slnt" -5; /* slant (italic-like) */ |
| 24 | } |
| 25 | |
| 26 | /* Registered axes */ |
| 27 | /* wght — weight (1-1000) */ |
| 28 | /* wdth — width (percentage) */ |
| 29 | /* slnt — slant (in degrees) */ |
| 30 | /* ital — italic (0 or 1) */ |
| 31 | /* opsz — optical size (point size) */ |
| 32 | |
| 33 | /* Custom axes (foundry-specific) */ |
| 34 | /* XHGT — x-height */ |
| 35 | /* YOPQ — y-optic quadrature */ |
| 36 | /* GRAD — grade (weight without width change) */ |
Google Fonts provides a vast library of open-source typefaces. While convenient, loading from the Google Fonts CDN introduces an external dependency and potential performance overhead. Self-hosting is preferred for production.
| 1 | /* Option 1: Link tag in HTML (simplest) */ |
| 2 | /* <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> */ |
| 3 | |
| 4 | /* Option 2: @import in CSS (slower — blocks rendering) */ |
| 5 | @import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"); |
| 6 | |
| 7 | /* Option 3: Self-hosted (best performance) */ |
| 8 | /* Download, subset, and host the font files yourself */ |
| 9 | @font-face { |
| 10 | font-family: "Inter"; |
| 11 | src: url("/fonts/inter-regular.woff2") format("woff2"); |
| 12 | font-weight: 400; |
| 13 | font-display: swap; |
| 14 | } |
| 15 | |
| 16 | /* Using the font */ |
| 17 | body { |
| 18 | font-family: "Inter", system-ui, sans-serif; |
| 19 | } |
| 20 | |
| 21 | /* Google Fonts with variable font support */ |
| 22 | /* <link href="https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,100..900&display=swap" rel="stylesheet"> */ |
| 23 | |
| 24 | /* Performance tips for Google Fonts */ |
| 25 | /* 1. Preconnect to origin */ |
| 26 | /* <link rel="preconnect" href="https://fonts.googleapis.com"> */ |
| 27 | /* <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> */ |
| 28 | /* 2. Use display=swap parameter */ |
| 29 | /* 3. Limit character subsets (e.g. &subset=latin) */ |
| 30 | /* 4. Limit weight variants requested */ |
warning
Self-hosting fonts gives you full control over performance, availability, and privacy. The process involves downloading font files, subsetting to needed characters, converting to WOFF2, and serving them from your own domain.
| 1 | /* Self-hosted font structure */ |
| 2 | @font-face { |
| 3 | font-family: "MyFont"; |
| 4 | src: url("/fonts/myfont.woff2") format("woff2"); |
| 5 | font-weight: 400; |
| 6 | font-style: normal; |
| 7 | font-display: swap; |
| 8 | } |
| 9 | |
| 10 | /* Subsetting — reduces file size by 70-90% */ |
| 11 | /* Only include characters your content needs */ |
| 12 | /* Tools: glyphhanger, fonttools, Google Fonts subsetter */ |
| 13 | |
| 14 | /* Font loading strategy */ |
| 15 | /* 1. Preload critical fonts */ |
| 16 | /* <link rel="preload" href="/fonts/myfont.woff2" as="font" crossorigin> */ |
| 17 | /* 2. Use font-display: swap */ |
| 18 | /* 3. Size-match fallback to reduce CLS */ |
| 19 | |
| 20 | /* Fallback font size adjustment */ |
| 21 | @font-face { |
| 22 | font-family: "MyFont-fallback"; |
| 23 | src: local(system-ui); |
| 24 | size-adjust: 105%; /* match custom font metrics */ |
| 25 | ascent-override: 88%; |
| 26 | descent-override: 20%; |
| 27 | line-gap-override: 10%; |
| 28 | } |
| 29 | |
| 30 | /* Use fallback */ |
| 31 | body { |
| 32 | font-family: "MyFont", "MyFont-fallback", sans-serif; |
| 33 | } |
Web fonts significantly impact page performance. The two main rendering issues are FOIT (Flash of Invisible Text — text hidden while font loads) and FOUT (Flash of Unstyled Text — fallback shown, then swapped). Managing these requires strategic font loading.
| 1 | /* FOIT vs FOUT strategies */ |
| 2 | |
| 3 | /* Strategy 1: font-display: swap */ |
| 4 | /* Best UX: text visible immediately, swaps when font loads */ |
| 5 | @font-face { |
| 6 | font-family: "Body"; |
| 7 | src: url("body.woff2") format("woff2"); |
| 8 | font-display: swap; |
| 9 | } |
| 10 | |
| 11 | /* Strategy 2: Font loading API (JavaScript) */ |
| 12 | // Use the CSS Font Loading API for fine control |
| 13 | // document.fonts.load('1em Inter').then(() => { |
| 14 | // document.documentElement.classList.add('fonts-loaded'); |
| 15 | // }); |
| 16 | |
| 17 | /* Strategy 3: Preload critical fonts */ |
| 18 | /* <link rel="preload" href="/fonts/inter.woff2" as="font" crossorigin> */ |
| 19 | /* <link rel="preload" href="/fonts/inter-bold.woff2" as="font" crossorigin> */ |
| 20 | |
| 21 | /* Strategy 4: Size-match fallback to minimize CLS */ |
| 22 | /* Measure and adjust fallback font metrics */ |
| 23 | @font-face { |
| 24 | font-family: "Inter-fallback"; |
| 25 | src: local(Arial); |
| 26 | size-adjust: 107%; |
| 27 | ascent-override: 90%; |
| 28 | descent-override: 22%; |
| 29 | } |
| 30 | |
| 31 | /* Performance checklist */ |
| 32 | /* 1. Use WOFF2 format (30-50% smaller than WOFF) */ |
| 33 | /* 2. Subset fonts (remove unused characters) */ |
| 34 | /* 3. Preload critical fonts */ |
| 35 | /* 4. Use font-display: swap for body text */ |
| 36 | /* 5. Self-host instead of CDN */ |
| 37 | /* 6. Limit font families to 2-3 per page */ |
| 38 | /* 7. Use variable fonts to reduce file count */ |
| Strategy | FOIT | FOUT | CLS | Complexity |
|---|---|---|---|---|
| font-display: swap | None | Yes | High (if metrics mismatch) | Low |
| font-display: optional | None | No (if not cached) | None (fallback used) | Low |
| Preload + swap | Minimal | Minimal | Low | Medium |
| Size-matched fallback | None | Minimal | Minimal | High |
font-size-adjust adjusts the font size to maintain consistent readability when the fallback font has different aspect ratios than the primary font. This minimizes layout shift during font swap.
| 1 | /* Adjust based on x-height */ |
| 2 | .x-height-adjust { |
| 3 | font-size-adjust: 0.5; /* adjust to match x-height ratio */ |
| 4 | } |
| 5 | |
| 6 | /* From-ex metric — use the primary font's x-height */ |
| 7 | .body { |
| 8 | font-family: "Inter", system-ui, sans-serif; |
| 9 | |
| 10 | /* Prevent layout shift: adjust fallback to match Inter's x-height */ |
| 11 | /* The x-height ratio of Inter is approximately 0.52 */ |
| 12 | font-size-adjust: 0.52; |
| 13 | } |
| 14 | |
| 15 | /* Newer syntax with from-font keyword */ |
| 16 | .auto-adjust { |
| 17 | font-family: "Inter", system-ui, sans-serif; |
| 18 | font-size-adjust: from-font; /* use primary font's metric */ |
| 19 | } |
| 20 | |
| 21 | /* Practical: consistent terminal font sizing */ |
| 22 | .terminal { |
| 23 | font-family: "Fira Code", "SF Mono", Menlo, monospace; |
| 24 | font-size-adjust: 0.48; /* typical monospace x-height */ |
| 25 | } |
| 26 | |
| 27 | /* size-adjust in @font-face (modern) */ |
| 28 | @font-face { |
| 29 | font-family: "Inter-fallback"; |
| 30 | src: local(Arial); |
| 31 | size-adjust: 107%; |
| 32 | } |
pro tip
pro tip