Tutorial

Typography for engineers.

You shipped a working product on a frontier model, and the interface reads like a config file. Oliver Reichenstein called web design “95% typography” back in 2006, and the ratio still holds for product UI: type covers more pixels than any other design decision you make. It is also the cheapest decision to get right. Typography has fewer degrees of freedom than color, layout, or motion: one typeface, one scale, a handful of spacing rules. This post is the hour that fixes it.

The short version
  • Default type fails because it makes no decisions: one size, one weight, lines that run 120 characters.
  • One family plus the mono you already use covers most products. System fonts win for dashboards and internal tools.
  • Pick a base and a ratio, generate sizes as tokens, and never invent a size per component.
  • Body at 1rem, line-height 1.6, and a 65ch measure fix more than any font swap.
  • Build hierarchy with two weights and three text colors before you reach for another size.

Why default type reads as unfinished

Typography is a stack of decisions: which face, which sizes, how tight the lines, how long they run, what gets emphasis. Defaults make none of them. The browser hands you one size and one weight; most framework resets flatten everything further. Headings differ from body only by being bigger, paragraphs stretch the full width of a 1200px container, and every piece of text carries equal visual weight.

The result is not ugly. It is mute. Type is how an interface tells the user what to read first, what to skim, and what to ignore, and default type says everything matters equally, which reads as nothing matters. Users cannot name the problem, but they have seen the same defaults in a thousand abandoned side projects, and they price your product accordingly. Craft in the type is the fastest signal that a team sweats the details everywhere else.

One good typeface, and when system fonts win

Decision one: a single family for the entire UI, plus the monospace you already need for code and data. That is not a starting point you graduate from; it is where most well-designed products stay. What to look for in a UI face:

  • Tall x-height and open shapes, so it stays legible at 13 and 14px.
  • At least a 400 and a 600, ideally as one variable font file.
  • Tabular figures (font-variant-numeric: tabular-nums), so numbers align in columns.
  • A distinct character you can defend. Inter, Geist, IBM Plex Sans, Source Sans 3, and General Sans all pass; they read differently, and any one of them beats a default.

And know when not to load one. System fonts win whenever the text is the product: dashboards, internal tools, docs, anything data-dense. Zero bytes over the wire, no flash of fallback text, and rendering tuned by each OS vendor for its own screens. The stack is one token:

:root{
  --font-ui: system-ui, -apple-system, "Segoe UI", Roboto,
             "Helvetica Neue", Arial, sans-serif;
  --font-mono: ui-monospace, "SF Mono", "Cascadia Code",
               Menlo, Consolas, monospace;
}
body{ font-family: var(--font-ui); }
code, pre{ font-family: var(--font-mono); }

If the brand shows, ship a webfont properly: one variable woff2, self-hosted, preloaded, with font-display: swap so text renders before the font arrives.

@font-face{
  font-family: "Inter Variable";
  src: url("/fonts/InterVariable.woff2") format("woff2");
  font-weight: 100 900;
  font-display: swap;
}
html{ font-family: "Inter Variable", var(--font-ui); }

The scale: a ratio, not a feeling

The second decision kills the worst habit in engineer-built UIs: inventing a font size per component. Pick a base (1rem, 16px) and a ratio, multiply upward, and store the results as tokens. A ratio of 1.2 (minor third) suits dense product UI; 1.25 to 1.333 suits marketing pages, where headlines need drama. Below the base, ignore the ratio and use the two standard UI steps, 14px and 12px.

:root{
  /* base 16px, ratio 1.25 above it */
  --text-xs:   0.75rem;   /* 12px  labels, captions   */
  --text-sm:   0.875rem;  /* 14px  secondary UI       */
  --text-base: 1rem;      /* 16px  body               */
  --text-lg:   1.25rem;   /* 20px  lead, h3           */
  --text-xl:   1.563rem;  /* 25px  h2                 */
  --text-2xl:  clamp(1.75rem, 1.1rem + 2.6vw, 2.441rem); /* h1 */
}

The clamp() on the top step makes it fluid: never below 1.75rem, never above 2.441rem, and 1.1rem + 2.6vw in between, so the headline scales with the viewport instead of snapping at breakpoints. Reserve fluid sizes for the top one or two steps; body text should not drift. Utopia generates full fluid scales if you want every step interpolated.

The discipline matters more than the ratio. When a size feels wrong, move to the adjacent step; never split the difference. Six sizes with clear jumps produce more hierarchy than sixteen sizes a few pixels apart, because hierarchy is contrast, and near-identical sizes have none.

Line height and measure: the two numbers nobody sets

Two properties improve readability more than any font swap, and defaults get both wrong. First, line height: the browser default of normal (about 1.2) is heading spacing, not body spacing. Body text wants 1.5 to 1.7; headings want 1.1 to 1.2, plus slightly negative letter-spacing once they pass 25px, because big type sets loose by default. Keep line-height unitless so it scales with nested font sizes.

Second, measure: the length of a line in characters. Readable prose runs 45 to 75 characters; 65ch is the classic target. A paragraph spanning a 1200px container runs past 120 characters, and the eye loses the return sweep to the next line. Constrain the text, not the container:

body{ line-height: 1.6; }
h1, h2, h3{
  line-height: 1.15;
  letter-spacing: -0.015em;
  text-wrap: balance;
}
p, li{ max-width: 65ch; }

text-wrap: balance is a free win: it evens out ragged two-line headings, the kind that break with one orphaned word on the second line.

Weight and hierarchy: two weights, three colors

Engineers reach for size to show importance, which is why unpolished UIs have eight font sizes and still no hierarchy. You have three levers: size, weight, and color. Use the second and third first.

  • Two weights: 400 for body, 600 for headings and emphasis. A 500 for UI labels is the defensible third. Skip 300 below 18px; it disappears on low-DPI screens.
  • Three text colors: primary for content, secondary for supporting text, tertiary for metadata. Define them as tokens, not one-off grays.
  • Letter-spacing only on uppercase. Small caps labels want 0.06 to 0.1em of tracking. Lowercase never gets letter-spaced; it breaks the word shapes readers recognize.
:root{
  --text-primary:   #16181d;
  --text-secondary: #565b66;
  --text-tertiary:  #8a8f9a;
}
.card-label{
  font-size: var(--text-xs);
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--text-tertiary);
}
.card-title{ font-size: var(--text-base); font-weight: 600; color: var(--text-primary); }
.card-body{  font-size: var(--text-sm); color: var(--text-secondary); }
.card-metric{ font-variant-numeric: tabular-nums; }

That card has full hierarchy using two sizes. The label recedes through color and scale, the title leads through weight, the body sits back through color alone. This is the pattern to steal for every list row, settings section, and stat block you ship.

Pairing rules

The default answer to “which second font?” is none. One family plus your mono is already a pairing, and it is the pairing running most of the products you admire. Add a second family only when the product has two registers, such as an editorial marketing site in front of a utilitarian app. Then three rules:

  • Contrast the category, not the flavor: a serif with a sans, a display face with a text face. Two similar grotesques read as a mistake.
  • Match the proportions: similar x-heights and widths keep mixed lines from jittering.
  • Give each a strict job. Serif for headlines only, sans for everything else. A font that appears in two roles is a font too many.

Where to find good fonts

Quality varies wildly on free platforms, so search by name, not by browsing. Google Fonts carries Inter, Source Sans 3, and IBM Plex; Fontshare gives away genuinely well-drawn families like General Sans and Switzer; Fontsource packages both for self-hosting via npm; Modern Font Stacks maps what system stacks look like on every OS. When the brand justifies a budget, foundries like Klim, Grilli Type, and Commercial Type sell distinction no free library has, and a style license costs less than a day of engineering time. Our design taste resources page keeps the full annotated list.

The before and after

Here is the type CSS most shipped side projects carry, whether written or inherited:

/* before: decisions nobody made */
body{ font-family: Arial; font-size: 14px; line-height: normal; }
h2{ font-size: 18px; font-weight: bold; }
.container{ max-width: 1200px; }   /* paragraphs run 140+ chars */

And the after, which is nothing but the tokens above, applied:

/* after: six tokens, three rules */
body{
  font-family: var(--font-ui);
  font-size: var(--text-base);
  line-height: 1.6;
  color: var(--text-secondary);
}
h2{
  font-size: var(--text-xl);
  font-weight: 600;
  line-height: 1.15;
  color: var(--text-primary);
}
p, li{ max-width: 65ch; }

Walk the diff and every change is a decision with a reason. Body moves from 14px to 16px because 14 is a UI size, not a reading size. Line height moves from about 1.2 to 1.6 because prose needs air. The heading jumps a full scale step and a weight instead of four timid pixels. Body color drops to secondary so headings lead without shouting. Lines stop at 65 characters so the eye finds its way back.

The hour: ten minutes choosing the family (or committing to the system stack), fifteen writing the tokens, twenty applying them and deleting every hardcoded font-size they replace, and fifteen comparing before and after screenshots at arm’s length. If the after does not obviously read better, you changed fonts instead of decisions; reread from the scale down.

This post is part of our design taste series for engineers. Deconstructing great interfaces teaches the analysis method behind these rules, and spacing, color, and hierarchy picks up where the type tokens end.

Learn this beside the people building it.

Membership is free. Masterclasses from industry leaders, hackathons where you finish something the same day, and mentor circles matched to what you want to learn. For engineers and creatives alike, across film, design, image, sound, and story.