type
Most interfaces are 90% text. If the text is defaulted, the interface is defaulted, and no amount of colour or motion hides it.
Choosing the family
One sans for the interface. One mono for data, code, shortcuts and labels that should read as system. A display face only on marketing pages, and only if the sans cannot carry it.
Pick for the job:
| Job | Traits | Reach for |
|---|---|---|
| Dense product UI | Neutral, tight apertures, good at 12–14px, tabular figures | Geist, Inter, SF-adjacent grotesks |
| Reading (docs, articles) | Larger x-height, wider spacing, optical sizing if available | Source-style humanists, or the same sans at 16px+ with looser leading |
| Data, code, labels | Mono with clear 0/O and 1/l, ligatures optional | Geist Mono, JetBrains Mono, Berkeley Mono |
| Display | Has an opinion; pairs by contrast with the sans, not similarity | one, chosen against the brand |
If the project already has a family, use it. Changing fonts is a redesign, not a fix.
Never: more than two families in product UI; a decorative face for body; a rounded face to "feel friendly".
Two scales, not one
Product UI and reading UI want different bases. Pick which the screen is.
Product scale (tools, dashboards, admin, lists)
| Role | Size | Weight | Leading | Tracking |
|---|---|---|---|---|
| Page title | 20–22px | 500 | 1.15 | −0.02em to −0.03em |
| Section title | 14–15px | 500 | 1.3 | −0.015em |
| Row / body | 13px | 400 | 1.45 | −0.003em |
| Secondary | 12–12.5px | 400 | 1.5 | 0 |
| Label, meta, mono | 10.5–11px | 400–500 | 1.3 | 0.04–0.1em if uppercase |
| Numbers in tables | body size, mono or tabular | 400 | — | 0 |
Body at 13px is the sweet spot for dense tools when the family is designed for it. Do not go below 10.5px for anything a person must read.
Reading scale (docs, articles, settings with long copy)
| Role | Size | Weight | Leading | Tracking |
|---|---|---|---|---|
| H1 | 28–36px | 500–600 | 1.1 | −0.025em |
| H2 | 20–24px | 500 | 1.2 | −0.02em |
| H3 | 16–18px | 500 | 1.3 | −0.01em |
| Body | 16–17px | 400 | 1.6 | 0 |
| Small | 14px | 400 | 1.5 | 0 |
Measure: 55–70 characters. Set max-width in ch on the text container, not on the page.
Hierarchy without size
The mistake generated UI makes is making everything a different size. Three sizes are enough for most screens. Get hierarchy from:
- Colour: four foreground steps. Primary text, secondary, tertiary, faint. Meta text is a lighter step at the same size, not a smaller size.
- Weight: 400 and 500. 600 for a title, rarely. 700 almost never in a UI font: it fills in at small sizes.
- Space: a title is separated from what follows by more space than sits between rows.
- Case and family: mono uppercase with tracking for a section label reads as a label without being bigger or bolder.
A row can carry a name at 13px/500 in primary colour, a description at 12.5px/400 in secondary, and a domain at 10.5px mono in faint. Three levels, two sizes.
Leading
Tighter as size grows. Roughly:
- 10–12px: 1.4–1.5
- 13–14px: 1.45
- 16–18px: 1.5–1.6 (reading), 1.4 (UI)
- 20–24px: 1.2–1.3
- 28px+: 1.05–1.15
Titles that wrap to two lines are where wrong leading shows first. Set it there, then check the single-line case still sits right.
Tracking
Negative as size grows; slightly positive for small caps and mono labels.
- 24px+: −0.02em to −0.04em
- 14–20px: −0.01em to −0.02em
- 12–14px: 0 to −0.005em
- Uppercase labels at 10–11px: +0.06em to +0.1em
- Mono at any size: 0
Never track body text positive. Never letter-space a lowercase word.
Numbers
Any number that sits in a column, changes over time, or is compared to another uses tabular figures:
.tabular { font-variant-numeric: tabular-nums; }
Counters, prices, timestamps, row counts, percentages, keyboard shortcuts. Mono is the stronger version of the same signal; use it for values that are identifiers (IDs, hashes, hostnames) and tabular sans for values that are quantities.
Slashed zero (zero feature) in mono when 0 and O can be confused.
OpenType
Turn on what the face was designed with, sitewide:
html { font-feature-settings: "ss01", "cv11"; } /* per family: check its specimen */
Common ones worth checking: alternate a and g for a more neutral look, cv11 single-storey a in Inter and Geist, ss01 open digits, case for punctuation with caps, tnum (same as the CSS property above), calt for code ligatures if the team wants them.
Rendering
html {
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
Antialiased on macOS makes light text on dark backgrounds thinner and cleaner. On light backgrounds it is close to neutral. Set it once, at the root.
Wrapping and truncation
- Titles and headings:
text-wrap: balance. No more orphans. - Paragraphs:
text-wrap: prettywhere supported. - Rows that must stay one line:
truncate(overflow: hidden; text-overflow: ellipsis; white-space: nowrap) with amin-width: 0on the flex child, or it will not truncate. - Descriptions in cards:
line-clamp: 2, never 3+; if three lines are needed, the layout is wrong. - Do not truncate anything the user needs to act on (a name they must recognise, an error). Wrap it.
- Hyphenate long reading text with
hyphens: autoandlangset; never in UI.
Links in text
Underline, with text-underline-offset: 3px and text-decoration-color at the faint step, brightening on hover. Colour alone is not a link. In lists of items, the whole row is the link and it is not underlined.
Labels, buttons, inputs
- Sentence case everywhere. "Save changes", not "Save Changes". The only uppercase is small mono section labels.
- Button text: 13px/500 in product UI, same as the row name. Never smaller than the body around it.
- Input text: 16px on touch devices or the browser zooms the page. Use the same size on desktop or the field will change size between them; 14–15px is a workable compromise with a media query for touch.
- Placeholder: tertiary colour, never as the only label.
Loading fonts
- Self-host or use the framework's font loader; both give
font-display: swapand no layout shift when combined with size-adjust. - Preload only the weights you use above the fold. Two weights, not six.
- Variable font when the family offers one: one file, all weights, and animated weight if you ever want it (you probably do not).
- Fallback stack that matches metrics (
size-adjust,ascent-override) so the swap does not reflow.
Marks of defaulted type
| Sign | Fix |
|---|---|
| Five font sizes on one screen | Collapse to three; move hierarchy to colour and weight |
| Bold everywhere | 500 for emphasis, 400 default; bold is for one thing per screen |
| Title case buttons | Sentence case |
| 1.5 line-height on a 32px heading | 1.1 |
| Proportional digits in a table | tabular-nums |
| Orphan on a two-line title | text-wrap: balance |
| Grey text that fails contrast | Faint step is for decoration only; secondary text must pass 4.5:1 |
| Letter-spaced lowercase | Remove tracking; reserve it for uppercase mono |
From the catalogue
- Geist — sans, mono and pixel families designed for product UI; the scale above assumes something like it.
- Google Fonts, Pangram Pangram, Klim, ABC Dinamo — where good families come from.
- Modular Scale, Utopia — build a scale once instead of guessing.
- Wakamai Fondue — see which OpenType features a font actually has.
- Better Web Type — the reading-scale fundamentals.
- More at stealth.pm/assets and stealth.pm/tools.