8 min read
Choosing web fonts for real interfaces
A practical look at x-height, variable fonts, loading behavior, fallback stacks, and why some beautiful fonts fail in product UI.
Interface fonts need endurance
A typeface can look excellent in a hero section and still be a poor choice for a product interface. Marketing pages get to show a font at 64px for three seconds; an interface shows it at 13px for three hours. Interfaces need labels, buttons, small numerals, form controls, dense sidebars, and long text to remain clear after hours of use, and that is a different job than looking striking in a screenshot.
The fonts that survive this job share a few unglamorous traits: generous x-height, open apertures, clear letterforms, reliable numerals, and weights that remain distinct at small sizes. A quick test tells you most of what you need: set the candidate font at 12px and type "Illegal 10Ol1 — $1,024.50". If the capital I, lowercase l, and the digit 1 blur together, or the numerals wobble in a table column, the font will fight you in every data view you ship.
Numerals deserve their own check. Interfaces are full of numbers that need to line up: prices, percentages, timestamps, table columns. A font with only proportional oldstyle figures will make every column ragged. Look for tabular figures (font-variant-numeric: tabular-nums) before committing, not after the dashboard is built.
- X-height: lowercase letters should stay open and readable at 12–14px.
- Distinct letterforms: I/l/1 and O/0 must be tell-apart-able in isolation.
- Tabular figures: numbers that align in columns, ideally via font-variant-numeric.
- Weight separation: regular and medium must still look different at label sizes.
X-height decides more than style
Two fonts set at 16px can look two sizes apart. Font-size measures the em box, not the visible letters, and typefaces spend that em box differently. A font with a large x-height, like Inter or Source Sans 3, fills more of the box with lowercase letterforms; a font with elegant long extenders, like many editorial serifs, leaves lowercase text visibly smaller at the same nominal size.
This is why "just swap the font" is never just swapping the font. Replace a large-x-height sans with a smaller-x-height one and every label in the product quietly shrinks, even though no CSS value changed. Densities drift, buttons feel emptier, and the type scale you carefully built no longer means what it meant. The same invisible-metrics problem shows up vertically too, which is covered in why line-height is harder on the web than it looks.
So compare candidates at equal optical size, not equal font-size. Set a paragraph of real UI copy in both fonts and adjust one until the lowercase heights match, then judge character, spacing, and weight. CSS can even do this adjustment for you: font-size-adjust scales a font so its x-height matches a chosen ratio, which keeps swapped or fallback fonts at the same visible size.
/* Inter has an aspect (x-height / em) of ~0.545 */
.ui {
font-family: "Inter", "Helvetica Neue", Arial, sans-serif;
font-size-adjust: 0.545; /* fallbacks render at Inter's visible size */
}Variable fonts are useful when used intentionally
Variable fonts can reduce requests and give a system more nuance, especially on the weight and optical-size axes. One file of Inter Variable replaces four or five static weights; Roboto Flex exposes width and optical size on top; Fraunces shows how far display axes can go. For interfaces, the practically useful axes are weight (wght) and optical size (opsz) — optical size lets the same family render sturdier at 12px and more refined at 48px, automatically via font-optical-sizing: auto.
The tradeoff is that teams need constraints. An axis from 100 to 900 is an invitation to improvise, and improvised weights make a design system less consistent, not more expressive. The useful approach is to choose a small set of named weights, expose them as tokens, and treat the variable font as the source of those tokens rather than as permission to pick 437 for one particular card. Naming and structuring those tokens follows the same rules as the rest of the scale, covered in typography tokens for design systems.
One caution: check the file size before assuming the variable font wins. A variable font with many axes can be heavier than the two static weights you actually use. The decision is per-project math, not ideology.
@font-face {
font-family: "Inter Var";
src: url("/fonts/InterVariable.woff2") format("woff2");
font-weight: 100 900; /* one file, whole axis */
font-display: swap;
}
:root {
--weight-regular: 400;
--weight-medium: 520; /* tuned once, used everywhere */
--weight-semibold: 640;
}
.label { font-weight: var(--weight-medium); }Loading behavior is part of the typography
Web typography is not just the selected font; it is also everything users see before that font arrives. Browsers handle the gap in two classic ways: FOIT (flash of invisible text — text hidden until the font loads) and FOUT (flash of unstyled text — fallback shown, then swapped). Invisible text is the worse failure: content pages should never be unreadable because a font file is slow.
The font-display descriptor is where you choose. swap shows the fallback immediately and swaps when ready — right for body text and anything content-critical. optional goes further: if the font is not there almost immediately, the page simply keeps the fallback, which is a defensible choice for decorative display faces. block recreates FOIT and is almost never what an interface wants. For the one or two files that define the page, add a preload hint so the request starts before the CSS is parsed.
Self-hosting in WOFF2 keeps the file on your own domain, avoids a third-party connection, and makes preloading reliable. Subsetting — shipping only the character ranges you use — routinely cuts font files by half or more.
<link rel="preload" href="/fonts/InterVariable.woff2"
as="font" type="font/woff2" crossorigin>
/* app.css */
@font-face {
font-family: "Inter Var";
src: url("/fonts/InterVariable.woff2") format("woff2");
font-weight: 100 900;
font-display: swap; /* text stays readable while loading */
}Design the fallback, not just the font
If font-display: swap shows a fallback first, then the fallback is part of your typography whether you designed it or not. The visible cost of a careless fallback is layout shift: the fallback renders wider or taller than the web font, and when the swap happens the whole page reflows. That shift is measured as Cumulative Layout Shift, and fonts are one of its most common causes.
Modern CSS can close the gap almost completely. The size-adjust, ascent-override, descent-override, and line-gap-override descriptors let you wrap a local fallback font in an @font-face rule that mimics the web font's metrics, so the swap barely moves a pixel. Tools like Capsize and the Fontaine build plugin compute these values from the font files; Next.js does the same automatically via next/font.
Test the fallback state deliberately: throttle the network, block the font request, and look at the page. Both states are what users actually see, and the fallback state is the one your slowest users live in.
@font-face {
font-family: "Inter Fallback";
src: local("Arial");
size-adjust: 107%; /* match Inter's width */
ascent-override: 90.2%; /* match Inter's vertical metrics */
descent-override: 22.48%;
line-gap-override: 0%;
}
body {
font-family: "Inter Var", "Inter Fallback", sans-serif;
}A font-selection checklist that survives production
Choosing an interface font is a sequence of small, testable decisions rather than one aesthetic judgment. Most bad outcomes come from testing only the happy path: the loaded font, at display size, at the design viewport, with perfect copy. Production is the other three quadrants.
The type scale is part of the same decision. A font choice interacts with size and weight everywhere, so evaluate candidates inside your real scale — including the fluid range, not just the design viewport. If your sizes are built with clamp, preview the candidate font across the whole viewport range in the TYPECLAMP generator and check that hierarchy still reads at both ends; the rules for that range are covered in responsive typography with CSS clamp. If the values come from a Figma file, convert them into clamp tokens first so the comparison uses production sizes.
None of these steps are glamorous, and that is the point: an interface font is infrastructure. The fonts that feel effortless after six months in production are the ones that were chosen with the boring checks, not despite them.
- Test at 12–14px with real UI strings, including "Il1 O0" and tabular numbers.
- Compare candidates at matched x-height, not matched font-size.
- Pick named weights from variable axes and freeze them as tokens.
- Use font-display: swap (or optional), preload the critical file, self-host WOFF2.
- Build a metric-matched fallback and test the unloaded state on purpose.
- Preview the font across the full fluid type scale before committing.