UI component typography
Interface text is scanned and acted on, not read continuously. Different failure
modes from body copy, and different rules.
1. When to invoke
- Typography inside buttons, labels, inputs, nav, tables, badges, tooltips.
- Numbers misaligning or jittering in tables or dashboards.
- Labels wrapping, truncating, or overflowing.
- Dense product UI where vertical space is contested.
Do not invoke for running prose (body-text-and-reading) or editorial layout
(editorial-web-typography).
2. Required context
- The densest screen the component appears on.
- Longest realistic string for every label — in every supported language.
- Whether text is user-generated (unbounded length).
- Whether numbers are compared vertically (tables, ledgers) or read singly.
- Touch vs. pointer, and the minimum touch target.
- Whether the platform provides Dynamic Type / OS text scaling.
3. Invariant principles
- UI text is scanned, not read. Optimise for recognition speed and
unambiguity, not reading comfort over long passages.
- Tabular figures are mandatory wherever numbers align vertically. Proportional
figures have varying widths, so digits won't form columns. Lining + tabular is
the combination for tables. (Lupton, Hochuli, Santa Maria)
1 l I and 0 O must be distinguishable in any interface showing codes,
IDs, quantities, or currency.
- Every label must survive its longest translation. German and Finnish
routinely run 30–40% longer than English.
- A label is not a placeholder. Placeholder-as-label disappears on focus and
usually fails contrast.
- Text must survive the WCAG 1.4.12 spacing override — dense components are
where this breaks first.
- Truncation is a last resort and must be recoverable — never truncate the only
copy of information without a way to see it.
4. Context-dependent heuristics
Sizing. UI text usually runs smaller than body copy because it's scanned, not
read — but small type raises the cost of a poor typeface. At 12–14px, aperture and
counter behavior matter more than at 16px. Prefer a face with open apertures and a
generous x-height for UI (see typeface-anatomy). Don't drop below ~12px for
anything carrying meaning; never for anything a user must act on.
Line-height in components. Tighter than body text — a single-line label doesn't
need 1.5. Around 1.2–1.35 for single-line UI strings, but note this is component
line-height, not the paragraph rule. Any multi-line text inside a component reverts
to body-text logic. WCAG 1.4.12 still requires the layout to survive a 1.5 override.
Figure styles — the four combinations.
|
Tabular (uniform width) |
Proportional (varying width) |
| Lining (cap height, uniform) |
Tables, dashboards, prices, data |
Buttons, single values in UI |
| Oldstyle (ascenders/descenders) |
Rare |
Running prose, editorial text |
.table-numeric { font-variant-numeric: tabular-nums lining-nums; }
.prose { font-variant-numeric: oldstyle-nums proportional-nums; }
Full tag/property lookup in ../type/references/opentype-features.md.
Lining figures are usually cap height or slightly less and are normally set on a
constant (en) body, which is what makes them tabular-capable (Hochuli).
Multiplexed typefaces solve the hover-reflow problem. In most families a heavier
weight is also wider, so bolding a nav item or a tab on hover shifts everything
after it. A multiplexed face is designed so every weight occupies exactly the
same horizontal space — "a word set in a light weight will take up the exact same
horizontal space as a word set in a heavy weight" (Stocks §31). Nothing moves.
Where a multiplexed face isn't available, the alternatives in order:
- A variable font's
GRAD (grade) axis — changes apparent weight without
changing width. See variable-fonts.
- Reserve the bold width up front (render the bold string invisibly to size the
container, or set
min-width).
- Signal the state with something other than weight — colour, an underline, a
background.
Never let a hover state reflow a navigation bar.
Labels and truncation. Design for the longest string first. Options, in order of
preference: allow wrapping; allow the container to grow; shorten the copy; truncate
with the full value available on hover/focus/detail. Truncating by default because
the design assumed English is a localisation bug.
Alignment in tables. Numbers right-aligned (or decimal-aligned) so magnitudes
compare; text left-aligned for a familiar entry point into reading; a column of
icons or single glyphs centered, to avoid the lopsided look a left or right rag
gives a symbol column. Headers match their column's data alignment. (Lupton,
Type on Screen)
Case. Use text-transform: uppercase rather than typing capitals, so screen
readers, search, and translation receive the real string. Add tracking whenever you
do — see microtypography.
5. Failure patterns
| Pattern |
Cause |
Fix |
| Numbers jitter/shift between rows |
Proportional figures |
font-variant-numeric: tabular-nums |
| Prices don't line up |
Not decimal-aligned |
Right- or decimal-align |
| Button text wraps in German |
Sized to English |
Test longest locale; allow growth |
| Label vanishes when typing |
Placeholder-as-label |
Real <label> |
0 vs O confusion in codes |
Ambiguous digits |
Face with disambiguated digits |
| Dense table unreadable at 200% zoom |
Fixed row heights |
min-height, intrinsic sizing |
| Layout collapses under 1.4.12 override |
Fixed heights in dense components |
Let containers grow |
| Screen reader spells out a nav item |
Literal capitals typed in source |
text-transform |
| Error message truncated |
Fixed-height container |
Let it grow; errors are critical |
| Text illegible at 11px |
Too small, closed apertures |
Raise size; choose an open-aperture face |
6. Evaluation procedure
- Render each component with its longest realistic string in the longest
supported locale.
- Render with the shortest string too — components often break empty.
- In any table: confirm
tabular-nums, and confirm numeric columns align.
- Check
1 l I / 0 O in the shipping face at the shipping size.
- Apply the WCAG 1.4.12 override; confirm nothing clips or overlaps.
- Zoom to 200%; confirm no loss of content or function.
- Check contrast for every state — placeholder, disabled, hover, error.
- Confirm touch targets meet minimum size independent of text size.
- Check any truncation has a recovery path.
7. Output format
Component: <name>
Size/line-height: <n>/<n> — rationale: <scan vs read, density>
Figures: <lining|oldstyle> + <tabular|proportional> — because <alignment need>
Longest string tested: "<string>" (<locale>) — <fits|wraps|truncates>
Digit disambiguation: <1lI ok?> <0O ok?>
1.4.12 override: <pass/fail> · 200% zoom: <pass/fail>
Contrast by state: default <n> · placeholder <n> · disabled <n> · error <n>
Truncation: <none|recoverable via …>
8. Examples
Dashboard table where "the numbers look wobbly".
The face defaults to proportional figures, so 1 is narrower than 8 and the
digits don't form columns — the wobble is real, not perceptual. Fix is
font-variant-numeric: tabular-nums lining-nums on numeric cells plus
right-alignment so magnitudes compare down the column. Verified the face actually
ships a tnum set (some don't, in which case the fallback is a monospace numeric
face for those cells).
Primary button breaking in German.
"Save changes" → "Änderungen speichern" is 22 characters vs 12. The button has a
fixed width sized to English, so the German string truncates. Fix in order:
remove the fixed width and let the button size to content with a min-width;
allow two-line wrapping with a tighter component line-height; only then consider
shorter copy. Verified at 200% zoom and under the 1.4.12 override — with the
fixed width removed, both pass.
9. Counterexamples
- ❌ "Use the placeholder as the label to save space." — Disappears on input, usually
fails contrast, and breaks screen-reader association.
- ❌ "Set the button width so the layout stays consistent." — Guarantees a
localisation failure.
- ❌ "Body line-height is 1.5, so use 1.5 everywhere." — Single-line UI labels don't
need paragraph leading; but the layout must still survive a 1.5 override.
- ❌ "Numbers are fine, the font looks aligned." — Verify
tabular-nums explicitly;
proportional figures can look aligned in one sample and drift in another.
- ❌ "Truncate with an ellipsis, users can guess." — Truncation without recovery
destroys information.
- ❌ Typing
SUBMIT in the source to get uppercase. — Use text-transform.
- ❌ "10px is fine for secondary labels." — If it carries meaning, it must be legible.
10. Source citations
- Lupton, Thinking with Type 3e — the four figure styles: lining numerals uniform
in height and resembling capitals; oldstyle/nonlining resembling lowercase with
ascenders and descenders; tabular with uniform widths "ideal for tables and
charts"; proportional with varying widths; lining and nonlining can each be
tabular or proportional.
- Hochuli, Detail in Typography — lining figures usually cap height or slightly
less, normally set on a constant (en) body so they can serve as tabular figures.
- Santa Maria, On Web Typography — tabular figures keep numbers lined up in
vertical columns, making tables and spreadsheets easier to scan.
- Rutter, Web Typography — OpenType figure features; numerals and data tables.
- Lupton (ed.), Type on Screen — typography and data display; designing data
tables; typography in interfaces.
- Brown, Flexible Typesetting — calibrated type for scanning complex information:
tabular data, navigation, infographics, code.
- WCAG 2.2 §1.4.3, §1.4.4, §1.4.12.
- MDN —
font-variant-numeric.
1---2name: ui-component-typography3description: Use when setting type inside interface components — buttons, labels, inputs, form errors, navigation, badges, tooltips, tables, dense dashboards. Also use when numbers jitter in a table, labels wrap unpredictably, UI text is illegible at small sizes, or a component's text breaks in another language.4---56# UI component typography78Interface text is scanned and acted on, not read continuously. Different failure9modes from body copy, and different rules.1011## 1. When to invoke1213- Typography inside buttons, labels, inputs, nav, tables, badges, tooltips.14- Numbers misaligning or jittering in tables or dashboards.15- Labels wrapping, truncating, or overflowing.16- Dense product UI where vertical space is contested.1718**Do not** invoke for running prose (`body-text-and-reading`) or editorial layout19(`editorial-web-typography`).2021## 2. Required context2223- The **densest** screen the component appears on.24- **Longest realistic string** for every label — in every supported language.25- Whether text is **user-generated** (unbounded length).26- Whether numbers are **compared vertically** (tables, ledgers) or read singly.27- Touch vs. pointer, and the minimum touch target.28- Whether the platform provides **Dynamic Type / OS text scaling**.2930## 3. Invariant principles3132- **UI text is scanned, not read.** Optimise for recognition speed and33 unambiguity, not reading comfort over long passages.34- **Tabular figures are mandatory wherever numbers align vertically.** Proportional35 figures have varying widths, so digits won't form columns. Lining + tabular is36 the combination for tables. (Lupton, Hochuli, Santa Maria)37- **`1 l I` and `0 O` must be distinguishable** in any interface showing codes,38 IDs, quantities, or currency.39- **Every label must survive its longest translation.** German and Finnish40 routinely run 30–40% longer than English.41- **A label is not a placeholder.** Placeholder-as-label disappears on focus and42 usually fails contrast.43- **Text must survive the WCAG 1.4.12 spacing override** — dense components are44 where this breaks first.45- **Truncation is a last resort and must be recoverable** — never truncate the only46 copy of information without a way to see it.4748## 4. Context-dependent heuristics4950**Sizing.** UI text usually runs smaller than body copy because it's scanned, not51read — but small type raises the cost of a poor typeface. At 12–14px, aperture and52counter behavior matter more than at 16px. Prefer a face with open apertures and a53generous x-height for UI (see `typeface-anatomy`). Don't drop below ~12px for54anything carrying meaning; never for anything a user must act on.5556**Line-height in components.** Tighter than body text — a single-line label doesn't57need 1.5. Around 1.2–1.35 for single-line UI strings, but note this is *component*58line-height, not the paragraph rule. Any multi-line text inside a component reverts59to body-text logic. WCAG 1.4.12 still requires the layout to survive a 1.5 override.6061**Figure styles — the four combinations.**6263| | Tabular (uniform width) | Proportional (varying width) |64|---|---|---|65| **Lining** (cap height, uniform) | Tables, dashboards, prices, data | Buttons, single values in UI |66| **Oldstyle** (ascenders/descenders) | Rare | Running prose, editorial text |6768```css69.table-numeric { font-variant-numeric: tabular-nums lining-nums; }70.prose { font-variant-numeric: oldstyle-nums proportional-nums; }71```7273Full tag/property lookup in `../type/references/opentype-features.md`.7475Lining figures are usually cap height or slightly less and are normally set on a76constant (en) body, which is what makes them tabular-capable (Hochuli).7778**Multiplexed typefaces solve the hover-reflow problem.** In most families a heavier79weight is also wider, so bolding a nav item or a tab on hover shifts everything80after it. A **multiplexed** face is designed so every weight occupies exactly the81same horizontal space — "a word set in a light weight will take up the exact same82horizontal space as a word set in a heavy weight" (Stocks §31). Nothing moves.8384Where a multiplexed face isn't available, the alternatives in order:85861. A variable font's **`GRAD` (grade)** axis — changes apparent weight without87 changing width. See `variable-fonts`.882. Reserve the bold width up front (render the bold string invisibly to size the89 container, or set `min-width`).903. Signal the state with something other than weight — colour, an underline, a91 background.9293Never let a hover state reflow a navigation bar.9495**Labels and truncation.** Design for the longest string first. Options, in order of96preference: allow wrapping; allow the container to grow; shorten the copy; truncate97with the full value available on hover/focus/detail. Truncating by default because98the design assumed English is a localisation bug.99100**Alignment in tables.** Numbers right-aligned (or decimal-aligned) so magnitudes101compare; text left-aligned for a familiar entry point into reading; a column of102icons or single glyphs centered, to avoid the lopsided look a left or right rag103gives a symbol column. Headers match their column's data alignment. (Lupton,104*Type on Screen*)105106**Case.** Use `text-transform: uppercase` rather than typing capitals, so screen107readers, search, and translation receive the real string. Add tracking whenever you108do — see `microtypography`.109110## 5. Failure patterns111112| Pattern | Cause | Fix |113|---|---|---|114| Numbers jitter/shift between rows | Proportional figures | `font-variant-numeric: tabular-nums` |115| Prices don't line up | Not decimal-aligned | Right- or decimal-align |116| Button text wraps in German | Sized to English | Test longest locale; allow growth |117| Label vanishes when typing | Placeholder-as-label | Real `<label>` |118| `0` vs `O` confusion in codes | Ambiguous digits | Face with disambiguated digits |119| Dense table unreadable at 200% zoom | Fixed row heights | `min-height`, intrinsic sizing |120| Layout collapses under 1.4.12 override | Fixed heights in dense components | Let containers grow |121| Screen reader spells out a nav item | Literal capitals typed in source | `text-transform` |122| Error message truncated | Fixed-height container | Let it grow; errors are critical |123| Text illegible at 11px | Too small, closed apertures | Raise size; choose an open-aperture face |124125## 6. Evaluation procedure1261271. Render each component with its **longest realistic string** in the **longest128 supported locale**.1292. Render with the **shortest** string too — components often break empty.1303. In any table: confirm `tabular-nums`, and confirm numeric columns align.1314. Check `1 l I` / `0 O` in the shipping face at the shipping size.1325. Apply the WCAG 1.4.12 override; confirm nothing clips or overlaps.1336. Zoom to 200%; confirm no loss of content or function.1347. Check contrast for *every* state — placeholder, disabled, hover, error.1358. Confirm touch targets meet minimum size independent of text size.1369. Check any truncation has a recovery path.137138## 7. Output format139140```141Component: <name>142Size/line-height: <n>/<n> — rationale: <scan vs read, density>143Figures: <lining|oldstyle> + <tabular|proportional> — because <alignment need>144Longest string tested: "<string>" (<locale>) — <fits|wraps|truncates>145Digit disambiguation: <1lI ok?> <0O ok?>1461.4.12 override: <pass/fail> · 200% zoom: <pass/fail>147Contrast by state: default <n> · placeholder <n> · disabled <n> · error <n>148Truncation: <none|recoverable via …>149```150151## 8. Examples152153**Dashboard table where "the numbers look wobbly".**154155> The face defaults to proportional figures, so `1` is narrower than `8` and the156> digits don't form columns — the wobble is real, not perceptual. Fix is157> `font-variant-numeric: tabular-nums lining-nums` on numeric cells plus158> right-alignment so magnitudes compare down the column. Verified the face actually159> ships a `tnum` set (some don't, in which case the fallback is a monospace numeric160> face for those cells).161162**Primary button breaking in German.**163164> "Save changes" → "Änderungen speichern" is 22 characters vs 12. The button has a165> fixed width sized to English, so the German string truncates. Fix in order:166> remove the fixed width and let the button size to content with a `min-width`;167> allow two-line wrapping with a tighter component line-height; only then consider168> shorter copy. Verified at 200% zoom and under the 1.4.12 override — with the169> fixed width removed, both pass.170171## 9. Counterexamples172173- ❌ "Use the placeholder as the label to save space." — Disappears on input, usually174 fails contrast, and breaks screen-reader association.175- ❌ "Set the button width so the layout stays consistent." — Guarantees a176 localisation failure.177- ❌ "Body line-height is 1.5, so use 1.5 everywhere." — Single-line UI labels don't178 need paragraph leading; but the layout must still *survive* a 1.5 override.179- ❌ "Numbers are fine, the font looks aligned." — Verify `tabular-nums` explicitly;180 proportional figures can look aligned in one sample and drift in another.181- ❌ "Truncate with an ellipsis, users can guess." — Truncation without recovery182 destroys information.183- ❌ Typing `SUBMIT` in the source to get uppercase. — Use `text-transform`.184- ❌ "10px is fine for secondary labels." — If it carries meaning, it must be legible.185186## 10. Source citations187188- Lupton, *Thinking with Type* 3e — the four figure styles: lining numerals uniform189 in height and resembling capitals; oldstyle/nonlining resembling lowercase with190 ascenders and descenders; tabular with uniform widths "ideal for tables and191 charts"; proportional with varying widths; lining and nonlining can each be192 tabular or proportional.193- Hochuli, *Detail in Typography* — lining figures usually cap height or slightly194 less, normally set on a constant (en) body so they can serve as tabular figures.195- Santa Maria, *On Web Typography* — tabular figures keep numbers lined up in196 vertical columns, making tables and spreadsheets easier to scan.197- Rutter, *Web Typography* — OpenType figure features; numerals and data tables.198- Lupton (ed.), *Type on Screen* — typography and data display; designing data199 tables; typography in interfaces.200- Brown, *Flexible Typesetting* — calibrated type for scanning complex information:201 tabular data, navigation, infographics, code.202- WCAG 2.2 §1.4.3, §1.4.4, §1.4.12.203- MDN — [`font-variant-numeric`](https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant-numeric).