Fonts and @font-face: the font Shorthand, font-family Stacks, Variable Fonts and font-variation-settings
You define a self-hosted variable font with @font-face using a weight range (font-weight: 100 900), a separate italic face and unicode-range; you set the weight with font-weight and keep font-variation-settings for custom axes. With Fraunces on Bookstore's headings we measure, in Chromium and WebKit, how the font shorthand resets line-height, how a single-value descriptor breaks 700, faux italic, and which file the letters ğ, ş and İ come from.
Version note: This post follows the CSS Fonts Module Level 4 specification and the November 2026 state of Baseline (web-features 3.46.0); the examples were run in Chromium 158 and WebKit 27.2 (Playwright 1.65), and Firefox support comes from web-features and MDN browser-compat-data 8.1.9.
Guess First
This series is the hands-on companion to the CSS3: Flexbox, Grid and Modern CSS series. That series covered how the browser applies CSS and how layout works; here we take the property families we write every day one by one and answer the question "which property, with which value, gives me this look?". Every example uses Bookstore, the online bookstore used as the shared example across this site's series: the catalog card, the book detail page, the cart and the checkout screens.
The first post starts with fonts. On the catalog card we set the book title in a variable font. The card sets line-height: 1.6 and a font axis value, and the title defines its own font with the shorthand:
.card { line-height: 1.6; font-variation-settings: "SOFT" 100; }
.card .title { font: 600 1.25rem "Fraunces", serif; }
.w700-fvs { font-weight: 700; font-variation-settings: "wght" 400; }
<div class="card"><p class="title" id="title">Middlemarch</p></div>
<span class="w700-fvs">Middlemarch</span>
Two questions. Is the title's line-height the card's 1.6? Is the "SOFT" 100 we set on the card inherited by the title? We gave the span on the second line font-weight: 700; does it look bold on screen? Write your answers down; the measurements are in the post.
@font-face: Introducing a Font to the Browser
Bookstore's headings use Fraunces. Fraunces is a serif variable font released under the SIL Open Font License, which lets you use it for free and serve it from your own server; it has a weight axis (wght), an optical size axis (opsz) and two custom axes. I took the files from the @fontsource-variable/fraunces 5.3.0 package and copied them to web/fonts/, with the license text next to them. The price of self-hosting a font is that you introduce it to the browser yourself with @font-face:
/* Fraunces: OFL variable font, axes wght 100-900, opsz 9-144, SOFT, WONK */
@font-face {
font-family: "Fraunces";
src: url("../fonts/fraunces-latin-normal.woff2") format("woff2");
font-weight: 100 900;
font-style: normal;
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;
}
@font-face {
font-family: "Fraunces";
src: url("../fonts/fraunces-latin-ext-normal.woff2") format("woff2");
font-weight: 100 900;
font-style: normal;
font-display: swap;
unicode-range: U+0100-0130, U+0132-024F, U+20A0-20AB, U+20AD-20C0;
}
@font-face {
font-family: "Fraunces";
src: url("../fonts/fraunces-latin-italic.woff2") format("woff2");
font-weight: 100 900;
font-style: italic;
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;
}
@font-face {
font-family: "Fraunces";
src: url("../fonts/fraunces-latin-ext-italic.woff2") format("woff2");
font-weight: 100 900;
font-style: italic;
font-display: swap;
unicode-range: U+0100-0130, U+0132-024F, U+20A0-20AB, U+20AD-20C0;
}
Each @font-face defines a "face", and its descriptors say: when text with this family name, in this style and weight range, is requested, use this file.
src is a comma-separated list of candidates; the browser picks the first one it supports. url() downloads the file, while local("Fraunces") looks for a copy installed on the user's machine. I do not write local() when self-hosting a variable font: the version the user installed may have different axes or different metrics, and the page would look different from machine to machine. The format("woff2") hint lets the browser skip a format it does not support without downloading it. WOFF2 compresses the font with Brotli and is enough on its own today; there is no need for a fallback in an older font format. The format("woff2-variations") spelling you see in older code was meant to mark variable fonts; it can now be written as format("woff2") tech(variations). The variable file also works with a plain format("woff2").
font-weight: 100 900 is a range, and it is the most important line for a variable font. It tells the browser "this single file covers every weight from 100 to 900". When you omit the descriptor entirely, today's engines use the file's own range, but the font-weight: normal or 400 line often seen in ready-made templates narrows the range down to a single value; we will measure what happens in the pitfalls section. font-style is matched the same way: we define the italic file as a separate face with font-style: italic, keeping the same family name.
font-display decides how text looks while the font has not arrived yet. For a "block" period the browser draws the text with an invisible fallback, then for a "swap" period it shows a visible fallback and swaps when the font arrives within that time. block means a short invisible period and an unlimited swap, swap means almost no invisible period and an unlimited swap, fallback means a short swap period, and optional means no swap at all: "if it has not arrived, do not use it on this page". The specification leaves the exact durations to the browser rather than giving milliseconds. Which one to choose, preloading, and the layout shift a swap causes are covered in detail in the image and font optimization post of the Web Performance series.
unicode-range says which characters this face is downloaded for. We use Fraunces split into two files: the latin file has the basic Latin letters, the latin-ext file has extended letters such as ğ, ş and İ. The syntax accepts a single code point (U+0131), a range (U+0100-0130) or a wildcard (U+4??). The browser downloads a face only when a character in that range is going to be drawn with that family on the page. There is a detail that matters for Turkish: the dotless ı (U+0131) is in Fontsource's latin file, while ğ (U+011F), ş (U+015F) and İ (U+0130) are in the latin-ext file. A Turkish heading downloads both files.
font-family Stacks and Generic Families
font-family is a priority list. For each character the browser tries the list from start to end and uses the first font that contains that character; the list ends with a generic family. Bookstore's base styles define two stacks:
:root {
--font-body: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", sans-serif;
--font-display: "Fraunces", Georgia, "Times New Roman", serif;
}
body {
font-family: var(--font-body);
font-size: 1rem;
line-height: 1.6;
}
h1, h2, h3 {
font-family: var(--font-display);
line-height: 1.2;
font-optical-sizing: auto;
}
h1 { font-size: 2.5rem; font-weight: 650; }
h2 { font-size: 1.5rem; font-weight: 600; }
For body text we use system-ui: it picks the operating system's interface font, such as Segoe UI on Windows and San Francisco on macOS, and needs no download. system-ui is Baseline widely available. The -apple-system after it is for old Safari and Firefox versions, and "Segoe UI" and Roboto are for old browsers that do not know system-ui. In the heading stack, Georgia takes over when Fraunces has not arrived yet or a character is missing from Fraunces.
Not all generic families are in the same state. serif, sans-serif and monospace exist everywhere; ui-sans-serif, ui-serif, ui-monospace and ui-rounded work only in Safari. An easy way to measure this is to put monospace after the generic family and look at the width: if the browser does not know the family, it falls back to monospace.
.generic .mono { font-family: monospace; }
.generic .ui { font-family: ui-sans-serif, monospace; }
.generic .system { font-family: system-ui, monospace; }
npm install
npx playwright install chromium webkit
node scripts/01-fonts.mjs
--- chromium
generic mono width: 108.0px
generic ui width: 108.0px
generic system width: 88.6px
--- webkit
generic mono width: 108.4px
generic ui width: 88.6px
generic system width: 88.6px
In Chromium the ui-sans-serif line is as wide as monospace (108.0px): the family was not recognized and the next font was used. In WebKit it matches the system font (88.6px). The practical result: you can put ui-sans-serif in a stack, but system-ui or concrete font names must follow it.
The font Shorthand: What You Reset Matters as Much as What You Write
Now back to the opening question. The lab page measures:
--- chromium
.card line-height: 38.4px | .title line-height: normal | font-variation-settings: normal
--- webkit
.card line-height: 38.400002px | .title line-height: normal | font-variation-settings: normal
The card's line-height is 38.4px, 1.6 times the 24px font. On the title it is normal: we did not write line-height in the shorthand, but a shorthand also sets the longhands you leave out back to their initial values. The card set "SOFT" 100 with font-variation-settings; this property is inherited, but the font shorthand also reset it to normal.
The rules of the font shorthand: font-size and font-family are required and the family comes last. font-style, font-variant (only small-caps) and font-weight come before the size; line-height can only be written right after the size with a / (font: 600 1.25rem/1.3 "Fraunces", serif). There is also a group of properties the shorthand can never set but always resets: font-variation-settings, font-feature-settings, font-kerning, font-optical-sizing, font-size-adjust and all the font-variant-* longhands.
That is why in component styles I use the font shorthand only when I really want to define a font from scratch: font: inherit in a form element reset is a good example. To change a heading's weight or size, writing longhands is safer.
Variable Fonts: font-weight: 650, font-variation-settings and font-optical-sizing
With static fonts each weight is a separate file, and if you write font-weight: 650 the browser picks the closest file (600 or 700). In a variable font the weight is an axis; 650 really produces a weight between 600 and 700. I drew the same word with different weights and measured its width:
.weights span { font-family: "Fraunces", serif; }
.w400 { font-weight: 400; }
.w600 { font-weight: 600; }
.w650 { font-weight: 650; }
.w700 { font-weight: 700; }
.w700-fvs { font-weight: 700; font-variation-settings: "wght" 400; }
weights w400 font-weight: 400 width: 149.6px
weights w600 font-weight: 600 width: 154.7px
weights w650 font-weight: 650 width: 157.3px
weights w700 font-weight: 700 width: 159.8px
weights w700-fvs font-weight: 700 width: 149.6px
The width grows steadily with the weight: 149.6px at 400, 157.3px at 650, 159.8px at 700. In Bookstore we gave h1 650; the in-between value the designer asks for when "600 is too light, 700 too heavy" is possible with a single file.
The last line answers the opening question. Even with font-weight: 700 the width equals 400: font-variation-settings applies axis values last and overrides the wght value that font-weight produced. The computed font-weight still shows 700, but the text on screen has weight 400. Someone looking in DevTools is puzzled: "the weight is 700, why is it thin?"
The rule is simple: for registered axes (wght, wdth, slnt, ital, opsz) always use the matching property: font-weight, font-stretch (its new name font-width is Safari-only so far), font-style, font-optical-sizing. Write font-variation-settings only for custom axes. In Fraunces these are the uppercase SOFT (softens the corners) and WONK (slanted variants of some letters). One more thing to watch: font-variation-settings does not merge piece by piece. If you write "SOFT" 100 in one place and "WONK" 1 in another, the second wipes out the first completely. To work around this, keep each axis in a custom property and combine them in a single font-variation-settings.
font-optical-sizing: auto is the default for fonts with an opsz axis and sets the axis from the font size: sturdier strokes and looser spacing at small sizes, delicate high-contrast forms at large sizes. I measured the difference on a 48px heading:
.optical span { font-family: "Fraunces", serif; font-size: 48px; }
.optical .off { font-optical-sizing: none; }
optical auto width: 117.4px
optical off width: 122.0px
With none the font returns to the default opsz value (14 in Fraunces) and draws wider letters meant for body text at 48px. That is why I wrote auto explicitly for headings in the base styles; it makes the intent clear against a change coming from a reset or a shorthand.
font-synthesis: Faux Bold and Faux Italic
If there is no suitable face for the requested style or weight, the browser synthesizes it: it slants upright letters for a faux italic and thickens strokes for a faux bold. We set the quote on the book detail page with font-style: italic. What would happen without a real italic file? I tried a family that defines only the upright file:
/* only the upright file: italic must be synthesized */
@font-face {
font-family: "Fraunces Upright";
src: url("../fonts/fraunces-latin-normal.woff2") format("woff2");
font-weight: 100 900;
font-style: normal;
}
.upright span { font-family: "Fraunces Upright", serif; }
.upright .real { font-family: "Fraunces", serif; }
.upright .italic { font-style: italic; }
.upright .no-synth { font-synthesis: none; }
--- chromium
upright plain width: 149.6px
upright italic width: 149.6px
upright italic no-synth width: 149.6px
upright real italic width: 140.7px
same pixels? .upright .plain vs .upright .italic: false
same pixels? .upright .plain vs .upright .no-synth: true
--- webkit
same pixels? .upright .plain vs .upright .italic: false
same pixels? .upright .plain vs .upright .no-synth: true
The faux italic has the same width as the upright text (149.6px) because it only slants the letters; the real italic file has its own letter widths (140.7px). The pixels differ too: synthesis happened. When we add font-synthesis: none, the text is drawn with exactly the same pixels as the upright file; the browser invents nothing even though italic was requested. Bookstore has a real italic file, so this is not a problem here, but in a project that forgets the italic file, these slanted, cramped letters are the first thing the designer will notice.
font-synthesis is a list: weight, style, small-caps and position. font-synthesis: style allows only italic synthesis, none turns all of it off. It has longhands too (such as font-synthesis-weight: none). In a project with a real italic and a real weight range, adding font-synthesis: none makes a missing face visible instead of quietly synthesizing it.
In Bookstore: Which Files Are Downloaded, and Where Do Turkish Letters Come From?
I opened the catalog and book detail pages and looked at which font files were requested and at the state of the faces in document.fonts:
node scripts/01-pages.mjs
node scripts/baseline.mjs font-face font-display font-family-system font-family-ui font-variation-settings font-optical-sizing font-synthesis font-metric-overrides font-size-adjust
catalog.html requested: fraunces-latin-normal.woff2
FontFace status: normal/latin: loaded | normal/latin-ext: unloaded | italic/latin: unloaded | italic/latin-ext: unloaded
book.html requested: fraunces-latin-normal.woff2, fraunces-latin-italic.woff2
FontFace status: normal/latin: loaded | normal/latin-ext: unloaded | italic/latin: loaded | italic/latin-ext: unloaded
On the catalog page the headings contain only basic Latin letters and a single file is downloaded; three faces stay unloaded. On the book detail page the quote is italic, so the italic file is downloaded as well. Thanks to unicode-range and separate style files, users do not download files for characters they never use.
To check Turkish characters, I put two lines in the lab. The first uses the normal Fraunces family, the second a family that deliberately defines only the latin file:
/* only the latin subset: no latin-ext face */
@font-face {
font-family: "Fraunces Latin";
src: url("../fonts/fraunces-latin-normal.woff2") format("woff2");
font-weight: 100 900;
unicode-range: U+0000-00FF, U+0131;
}
.glyphs .full { font-family: "Fraunces", serif; }
.glyphs .latin-only { font-family: "Fraunces Latin", Georgia, serif; }
<p class="glyphs" lang="tr">
<span class="full">ğ ş ı İ ç ö ü</span> <span class="latin-only">ğ ş ı İ ç ö ü</span>
</p>
font requests: fraunces-latin-normal.woff2, fraunces-latin-italic.woff2, fraunces-latin-ext-normal.woff2
.glyphs .full Fraunces x13
.glyphs .latin-only Fraunces x10, Georgia x3
The last two lines come from Chromium: the script reads, through the Chrome DevTools Protocol, the same data DevTools shows under "Rendered Fonts". In the normal family all 13 glyphs (7 letters and 6 spaces) come from Fraunces. In the family that defines only the latin file, 3 glyphs are drawn from Georgia: ğ, ş and İ. The dotless ı comes from Fraunces because U+0131 is in the latin range. On screen this means three letters of a heading drawn in a different font. It is easy to miss visually, especially when the fallback is a similar serif.
Which font looks good with Turkish characters in a Turkish interface, and how to choose weights and sizes, is the topic of the Typography post in the UI/UX Design Principles series. Subsetting a font for Turkish to shrink the file is covered in the image and font optimization post of the Web Performance series.
In Production
A font file is one of the largest resources on a page. Here the four files add up to about 500 KB, but thanks to unicode-range the catalog page downloads a single 121 KB file. When you add a new face, open the Network panel in DevTools with the font filter and check which page requests which file; you can run a script like this post's 01-pages.mjs in CI to catch an unexpected font request.
The metric difference between the fallback font and the web font makes text move when the font arrives. The size-adjust descriptor inside @font-face (Baseline newly available) scales the fallback font to reduce this difference; ascent-override, descent-override and line-gap-override are missing in Safari and are therefore not Baseline. Use them only as progressive enhancement; how to calculate them is in the layout shift post of the Web Performance series.
Put the license of a self-hosted font into the snapshot and next to the deployed files. The OFL allows you to distribute the font but requires the license text to travel with it.
Common Pitfalls
First, resetting line-height with the font shorthand without noticing. As we measured, the card's 1.6 turned back into normal on the title. Either write longhands or add line-height to the shorthand with a /.
Second, defining a variable font in @font-face with a single weight. I defined the same file once with font-weight: 400 and once with no weight descriptor at all, and gave the text 700:
/* the same file, declared once WITHOUT a weight range */
@font-face {
font-family: "Fraunces Single";
src: url("../fonts/fraunces-latin-normal.woff2") format("woff2");
font-weight: 400;
}
/* no font-weight descriptor at all */
@font-face {
font-family: "Fraunces Auto";
src: url("../fonts/fraunces-latin-normal.woff2") format("woff2");
}
.single span { font-family: "Fraunces Single", serif; }
.single .no-synth { font-synthesis: none; }
.single .auto { font-family: "Fraunces Auto", serif; }
--- chromium
single w400 font-weight: 400 width: 149.6px
single w700 font-weight: 700 width: 149.6px
single w700 no-synth font-weight: 700 width: 149.6px
single w700 auto font-weight: 700 width: 159.8px
same pixels? .single .w400 vs .single .w700: true
same pixels? .single .w400 vs .single .no-synth: true
--- webkit
single w400 font-weight: 400 width: 149.6px
single w700 font-weight: 700 width: 149.6px
single w700 no-synth font-weight: 700 width: 149.6px
single w700 auto font-weight: 700 width: 159.8px
same pixels? .single .w400 vs .single .w700: false
same pixels? .single .w400 vs .single .no-synth: true
With the face defined as font-weight: 400, neither engine produces the requested 700: the width on the first three lines equals 400. Chromium draws the text at weight 400 (the pixels match 400), while WebKit applies faux bold on top of 400 (the pixels differ). With font-synthesis: none WebKit also goes back to 400. The auto line, where we wrote no descriptor, is correct: 159.8px, a real 700. The problem is the single-value descriptor; many old templates and the CSS generated by font services write font-weight: normal in every face. For a variable font, writing the range (100 900) explicitly states the intent and prevents this bug. The static-font version of the same mistake is defining a separate file for every weight and missing the range a variable font offers in a single file.
Third, writing font-variation-settings: "wght" ... and overriding font-weight. The computed value shows 700 while 400 is drawn on screen, and the font-weight changes you write for hover, dark mode or a media query have no effect at all. Use the standard properties for registered axes.
Fourth, shortening unicode-range by hand and leaving Turkish letters out. When the fallback is a similar font, this bug may not show up in testing; check the rendered font on a page that contains Turkish characters.
Baseline Status
The status of the properties and descriptors in this post, read from the web-features package:
font-face widely 2019-03-20 Chrome 36, Firefox 39, Safari 10, iOS 10
font-display widely 2022-07-15 Chrome 60, Firefox 58, Safari 11.1, iOS 11.3
font-family-system widely 2024-03-07 Chrome 56, Firefox 92, Safari 11, iOS 11
font-family-ui not Baseline Chrome -, Firefox -, Safari 13.1, iOS 13.4
font-variation-settings widely 2021-03-05 Chrome 62, Firefox 62, Safari 11, iOS 11
font-optical-sizing widely 2022-09-24 Chrome 79, Firefox 62, Safari 13.1, iOS 13.4
font-synthesis widely 2024-07-06 Chrome 97, Firefox 34, Safari 9, iOS 9
font-metric-overrides not Baseline Chrome 87, Firefox 89, Safari -, iOS -
font-size-adjust newly 2024-07-25 Chrome 127, Firefox 118, Safari 17, iOS 17
| Feature | Status | Note |
|---|---|---|
@font-face, unicode-range, WOFF2 | Widely available | Nothing extra needed |
font-display | Widely available | Durations are left to the browser |
system-ui | Widely available | Together with concrete font names in the stack |
ui-sans-serif and the other ui-* | Not Baseline | Safari only; put a fallback after it |
font-variation-settings, font-optical-sizing | Widely available | For custom axes |
font-synthesis | Widely available | The position value is Firefox-only |
size-adjust (part of the font-size-adjust feature) | Newly available | Since 2024-07 |
ascent-override and the other metric overrides | Not Baseline | No Safari; progressive enhancement |
A Small Experiment
★☆☆ Rewrite the lab's .card .title rule so that it keeps the card's line-height and the SOFT axis. Confirm with node scripts/01-fonts.mjs that the first line changes.
★★☆ Add a Turkish sentence (with ğ and ş) to the quote in book.html. Guess which new file will be requested, then check with 01-pages.mjs.
★★★ Combine the SOFT and WONK axes in a single font-variation-settings using the --font-soft and --font-wonk custom properties; measure from the computed value that WONK is not lost when you change only --font-soft on the card.
In the next post we move from choosing the font to the properties that style the text: the text-decoration family and a readable link underline, why letter-spacing should be given in em, text-shadow, and vertical-align for aligning an inline icon next to the price.