Enter to open · arrow keys to move · results come from a build-time index, no network calls

Typography

Edit this page

Type carries most of the hierarchy in an interface. Get the scale, the measure and the loading right and a page reads as designed even with a plain font.

The scale#

Pick one ratio and generate every size from it. A modular scale beats arbitrary sizes for two reasons: adjacent steps are guaranteed to be visibly different, and the relationship between a heading and its body is the same everywhere, so the page has one rhythm instead of a per-component decision.

RatioNameCharacter
1.125Major secondVery tight; dense dashboards only
1.200Minor thirdCalm, information-dense product UI
1.250Major thirdThe reliable default for app and marketing UI
1.333Perfect fourthEditorial, strong display contrast
1.500Perfect fifthLoud; large jumps, few levels

A ratio of 1.25 from a 16px base:

css
:root {
  --text-xs:   0.64rem;  /* 10.24px - legal, dense table meta */
  --text-sm:   0.8rem;   /* 12.8px  - captions, labels */
  --text-base: 1rem;     /* 16px    - body */
  --text-lg:   1.25rem;  /* 20px    - lead paragraph, card title */
  --text-xl:   1.563rem; /* 25px    - h3 */
  --text-2xl:  1.953rem; /* 31.25px - h2 */
  --text-3xl:  2.441rem; /* 39px    - h1 */
  --text-4xl:  3.052rem; /* 48.8px  - display */
}

Rules for using it:

  • Use rem for type so a user's browser font-size setting is honoured. Never set a px font-size on html.
  • Skip steps between adjacent hierarchy levels. h2 at --text-2xl above body at --text-base reads as a level; one step apart reads as an accident.
  • Body copy sits at 16–18px. Below 16px is uncomfortable for sustained reading on a phone.
  • Four to six sizes on a page is plenty. A seventh size usually means a weight or colour change was the real intent.

For display sizes that must respond to viewport, interpolate with clamp() rather than adding breakpoint overrides:

css
h1 { font-size: clamp(2rem, 1.5rem + 2.5vw, 3.052rem); }

Include a rem term in the middle argument so the value still scales when the user changes their browser font size — a pure vw middle argument breaks zoom.

Line-height and measure#

Line-height is a function of size and measure, not one number. Large text needs proportionally less leading; long lines need more, because the eye needs help finding the next line's start.

ContextSizeline-height
Display heading40px+1.0–1.1
Section heading24–39px1.15–1.25
Card title, lead20–24px1.3–1.4
Body copy16–18px1.5–1.65
Dense UI, table cell12–14px1.35–1.45

Set it unitless so it scales with inherited font-size. Measure — the line length — belongs in ch, which is relative to the font's 0 width and therefore tracks the actual character count.

css
body    { line-height: 1.55; }
.prose  { max-inline-size: 68ch; }   /* ~45-75 characters reads comfortably */
h1, h2  { line-height: 1.15; text-wrap: balance; }
.caption{ max-inline-size: 45ch; }

45–75 characters is the readable band for body copy. Narrower forces too many return sweeps; wider loses the line. ch is an approximation of character count rather than an exact one, so verify in a capture.

Letter-spacing#

CaseTracking
Display and large headingsTighten: -0.02em to -0.03em
Section headingsSlight tighten: -0.01em
Body copyNone. Leave the font's designed spacing alone
All-caps labels, eyebrowsOpen up: 0.04em to 0.1em
Tabular numerals in a columnNone; use tabular-nums instead

Two hard rules. Never letterspace lowercase running text — the designer already spaced the pairs, and adding tracking breaks the word shapes readers recognise. Always letterspace all-caps, because uppercase glyphs were spaced for use inside lowercase words and look cramped in a run of capitals.

css
.display { letter-spacing: -0.025em; }
.eyebrow { text-transform: uppercase; letter-spacing: 0.08em; font-size: var(--text-sm); }

Pairing#

StrategyWhenRisk
One family, multiple weightsDefault choice. Almost always sufficientNone; this is the safe option
One family plus a monoAny product with code, IDs, or numeric dataNone
Display family plus text familyEditorial or marketing with a real display needMismatched proportions and x-height
Two text familiesRarely justifiedReads as indecision

The safest option is one well-made family used across a weight range. Hierarchy comes from size, weight, colour and space — not from a second typeface. If you do pair, contrast the families clearly (a geometric display against a neutral text face) rather than slightly; two similar sans faces look like a mistake.

Reserve weights: 400 for body, 500–600 for emphasis and UI labels, 700+ for headings. Do not use weights below 400 for body text — thin weights lose stroke contrast against the background and hurt contrast perception.

Variable fonts#

A variable font ships one file covering a weight range, so using 400, 500, 600 and 700 costs one download instead of four. Declare the range in font-weight inside @font-face:

css
@font-face {
  font-family: "Inter var";
  src: url("/fonts/inter-var.woff2") format("woff2-variations");
  font-weight: 100 900;          /* the axis range this file covers */
  font-style: normal;
  font-display: swap;
  unicode-range: U+0000-00FF;    /* subset: latin basic + supplement */
}

Do not synthesise weights the file does not have — the browser will fake bold by smearing outlines. If an axis exists, use font-variation-settings only for axes with no high-level property (optical size opsz, grade GRAD); use font-weight and font-stretch for the ones that do.

Loading without layout shift#

Fonts cause two visible problems: invisible text while loading, and reflow when the real font replaces the fallback. Address both.

MechanismWhat it doesUse it for
font-display: swapRenders the fallback immediately, swaps in the webfont when readyBody and heading text
font-display: optionalUses the webfont only if it is nearly instant; no swapNice-to-have faces where shift is worse than the wrong font
<link rel="preload">Starts the font request without waiting for CSS parseThe one or two fonts above the fold
unicode-range subsettingShips only the glyphs neededEvery self-hosted font
size-adjust, ascent-overrideScales the fallback to match the webfont's metricsKilling the reflow that swap causes
html
<!-- Preload only what renders above the fold; more preloads compete for bandwidth. -->
<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin>
css
/* Metric-compatible fallback: the local font is scaled so line boxes match,
   so the swap changes glyph shapes without moving anything. */
@font-face {
  font-family: "Inter fallback";
  src: local("Arial");
  size-adjust: 107%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}
body { font-family: "Inter var", "Inter fallback", sans-serif; }

Serve woff2 only — every browser that matters supports it, and additional formats just add bytes. Always set crossorigin on a font preload; without it the browser makes a second, non-anonymous request.

The system font stack#

The fastest font is the one already installed. Use this when the brand does not require a specific face:

css
:root {
  --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
               Arial, "Noto Sans", sans-serif, "Apple Color Emoji",
               "Segoe UI Emoji";
  --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
               "Liberation Mono", monospace;
  --font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
}

system-ui resolves to the platform UI face. Keep the explicit fallbacks after it: system-ui has historically resolved badly on some Linux configurations, and the emoji families at the end stop emoji from falling back to a serif face.

OpenType features#

FeaturePropertyUse for
Tabular figuresfont-variant-numeric: tabular-numsAny column of numbers, timers, prices
Slashed zerofont-variant-numeric: slashed-zeroIDs, hashes, codes, monospace data
Proportional figuresfont-variant-numeric: proportional-numsNumbers inside running prose
Fractionsfont-variant-numeric: diagonal-fractionsRecipes, measurements
Small capsfont-variant-caps: all-small-capsAbbreviations in prose
Ligature controlfont-variant-ligatures: noneCode, where fi/fl ligatures mislead
css
/* Without tabular-nums, digits have different widths and a column jitters
   whenever a value changes. */
td.numeric, .metric { font-variant-numeric: tabular-nums; }
code, .id           { font-variant-numeric: slashed-zero; font-variant-ligatures: none; }

Wrapping and hyphenation#

PropertyEffectApply to
text-wrap: balanceEvens line lengths across a short blockHeadings, blockquotes, card titles — short blocks only
text-wrap: prettyAvoids orphans and bad rags over a longer blockBody paragraphs
hyphens: autoHyphenates using the language dictionaryNarrow columns; needs lang on the element
overflow-wrap: anywhereBreaks unbreakable stringsURLs, tokens, user-supplied identifiers
text-wrap: nowrapPrevents any wrapLabels, badges, buttons that must stay one line
css
h1, h2, h3, blockquote { text-wrap: balance; }   /* short blocks only */
p, li                  { text-wrap: pretty; }
.narrow-column         { hyphens: auto; }        /* requires lang="..." */
.token                 { overflow-wrap: anywhere; }

text-wrap: balance applies only up to a limited number of lines, so it is for headings, not paragraphs. Hyphenation needs a correct lang attribute to pick the right dictionary; without it nothing happens. Both properties degrade silently where unsupported, so no fallback is required.

Review checklist#

  • Every size on the page comes from the scale.
  • Body copy has a max-inline-size in the 45–75ch band.
  • Line-height decreases as size increases.
  • No tracking on lowercase body; tracking present on all-caps.
  • Numeric columns use tabular-nums.
  • Fonts are woff2, subset, with font-display set and above-the-fold faces preloaded.
  • Nothing shifts between first paint and font load — verify with a capture, not by reading the CSS.