notes 6 min typography
The font a page declares is not the font you see
font-family is a wish list and the browser grants the first wish it can. Nearly every typography bug lives in the gap between the name in the stylesheet and the shapes on the screen.
Asking what font a page uses sounds like one question. It is three:
-
Which family the CSS asked for — the
font-familylist, in order. -
Which face the browser matched — after
@font-face, weight, style, stretch andunicode-rangehave all had a say. - Which one it actually painted with — per glyph, after loading succeeded, failed or timed out.
On a page that ships its own fonts properly, all three agree. On most pages at least one of them does not, and the gap is invisible if you only read the stylesheet — because the stylesheet is the request, not the outcome.
Four ways the declared font is not the rendered one #
- The file never arrived
- A wrong path, a blocked CDN, a missing CORS header. There is no error a visitor would notice — the browser moves to the next family in the list and the page renders in the fallback, looking almost right.
- The glyph was outside unicode-range
- A subset face covers part of the character set. A curly quote, an em dash or an accented name falls outside it and is painted by a different font in the same line of text.
- The weight was never shipped
- A stylesheet asking for
font-weight: 600with only 400 and 700 loaded does not fail. It matches the nearest face, and depending on the engine may thicken it artificially instead. - The face arrived late
font-display: swappaints the fallback first and swaps when the file lands, which is a layout shift by design.blockkeeps the text invisible until it lands. Both are correct configurations of the same tradeoff.
Synthetic bold is the loudest tell #
When a browser cannot find a bold face, it makes one — by drawing the regular weight a second time at a slight offset. It looks like bold at a glance and wrong at any size above a heading: the counters fill in, the stems thicken unevenly, and the letterfit is still the regular font's.
Synthetic oblique is the same trick for italic: a slant applied to upright letterforms, with none of the different shapes a real italic has. The tell is the lowercase a — in most true italics it stops being a double-storey letter, and in a synthesised one it never does.
Both are worth catching because both mean a weight is being requested that the page does not ship. That is a build problem, not a design one, and it is fixed in the font loading rather than in the CSS that asked.
body { font-synthesis: none; }
/* Every faux-bold and faux-italic on the page reverts to
its real weight. What changes is what was never shipped. */
The loading order that causes most of the damage #
A webfont is discovered late by design. The browser parses the HTML, requests the stylesheet, parses that, finds the @font-face, and only then learns the font exists — and in most builds it learns which file only once an element using that family is laid out.
- HTML arrives and parsing begins.
- The stylesheet is requested. Rendering is blocked until it lands.
-
@font-faceis parsed. The file is still not requested. - Layout finds text wanting that family. Now the file is requested.
-
It arrives, and depending on
font-displaythe text either swaps or appears.
Steps 2 to 4 are where the round trips accumulate, and they are why preload exists — it moves the request to the top of step 1:
<link rel="preload" as="font" type="font/woff2"
href="/fonts/inter-var.woff2" crossorigin>
<!-- crossorigin is required even for a same-origin font:
fonts are fetched in CORS mode, and a preload without
it downloads the file twice. -->
<!-- Preload only the faces above the fold. Preloading six
weights makes every one of them compete with the CSS. -->
A preload that is not used within a few seconds is worse than none at all. It spent bandwidth during the most contended moment of the page load and the browser will say so in the console — which is the one font warning worth reading.
How to see what actually rendered #
The browser exposes the loaded set. document.fonts is a FontFaceSet you can iterate for every face and its status, and check() answers whether a specific request can be met right now:
[...document.fonts].map((f) => [f.family, f.weight, f.status]);
document.fonts.check('600 16px Inter');
// false means: nothing will paint at 600 - it will be faked.
That covers what is available. Which face a particular run of text was painted with is harder — the platform decides per glyph — so the practical approach is to compare the computed font-family against the loaded set, and look for the synthesis tells above wherever the two disagree.
Variable fonts change the arithmetic #
Most of the failures above come from shipping a set of static weights and asking for one that is not in it. A variable font removes that category entirely: one file carries a continuous weight axis, so font-weight: 570 is a real instance rather than a synthesis.
- One file, not six
- A variable face at four weights is usually smaller than the four static files it replaces, and it is one request rather than four competing ones.
- No synthetic anything
- Every weight in the axis range exists. There is nothing for the browser to fake.
- The catch
@font-facemust declare the range —font-weight: 100 900— or the browser treats the file as a single weight and fakes the rest anyway, from a font that contained them all.
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2-variations');
font-weight: 100 900;
font-display: swap;
}
Fallback metrics, which almost nobody sets #
If a webfont swaps in and the text reflows, the cause is that the fallback has different metrics: a different x-height and different advance widths, so the same string occupies a different number of lines. That is a layout shift attributable entirely to typography.
@font-face {
font-family: 'Inter fallback';
src: local('Arial');
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22%;
}
body { font-family: 'Inter', 'Inter fallback', sans-serif; }
The font arrives, the metrics change, and every line under this paragraph moves down the page.
The font arrives, the metrics match, and nothing under this paragraph moves at all.
The local font is made to occupy the same space as the webfont, so the swap becomes a change of shape rather than a change of layout. It is the cheapest CLS fix on the web and it is almost never applied.
Written by Ján Turský
Building LoupeKit and other browser tools out of Bratislava, under Apptiary.
tools in this note