Typography
Two families, and at most three sizes and three weights per component. The limit forces hierarchy to come from contrast instead of variety.
The families#
Inter — every word#
Inter Variable is the interface face: labels, prose, headings, buttons, display sizes, and the landing accent (its true italic). Display type is Inter at display sizes, with tighter tracking and the optical-size axis doing the rest. It isn't a second text family.
We chose Inter for its variable optical-size axis, its case-sensitive forms (which we depend on, see below), excellent hinting at 13–14px, and character-level disambiguation (cv05, cv08) that matters when someone reads an account number.
--font-sans: var(--font-inter, "Inter Variable"), "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--font-display: var(--font-sans);
Geist Mono — every figure#
Every amount, rate, ratio and reference number resolves to --font-numeric and nothing else. So do code, keys and identifiers, which keeps the system at exactly two families.
--font-numeric: var(--font-geist-mono, "Geist Mono"), ui-monospace, "SF Mono", "Menlo", "Consolas", monospace;
Why a mono for figures: Inter's tnum aligns a column, but its digits keep the proportional rhythm of the surrounding text. In a 40-column grid the reader scans shapes down a column, not left to right. Geist Mono has a fixed advance, an unambiguous 1/7 and a zero that never reads as an O. A column forms a hard vertical edge, and a mistyped digit shows up as a wrong shape before anyone reads it as a number. Its digits look large next to Inter, so .numeric scales them by --numeric-size-adjust (0.94) to hold the line.
The display-figure law#
A figure may leave the figure face only when it never aligns with another figure.
Figure family="display" sets a solitary hero numeral in Inter at display size. A parent that stacks figures (KpiRow, a table) forces the figure face instead of trusting the caller.
| Allowed in display | Forbidden in display |
|---|---|
| The single hero balance on an account page | Any column of figures |
| A large standalone KPI figure in a card, when it's the only figure in that card | Table cells, statement rows, ledger amounts |
| Marketing / onboarding numbers | Anything comparative — two KPIs side by side |
Test: if a second figure could ever appear beside or beneath it, use the figure face.
No third family#
No serif, no second mono, no icon font. To get figures in Inter, set --font-numeric: var(--font-sans). Every component follows, because none of them names a family directly.
The scale#
Sizes are chosen for legibility. Line-heights are chosen to land on the 8px grid, which keeps type aligned to layout.
The text scale, standing on one baseline
Text#
| Token | Size / Line | Weight | Tracking | Use |
|---|---|---|---|---|
display-lg |
48 / 56 | 400 | −0.02em | Marketing, onboarding only |
display-md |
32 / 40 | 400 | −0.02em | Page hero, section opener |
title-lg |
24 / 32 | 500 | −0.01em | Page title |
title-md |
20 / 28 | 500 | −0.01em | Card title, dialog title |
title-sm |
16 / 24 | 500 | 0 | Section heading, list group header |
body-lg |
16 / 24 | 400 | 0 | Long-form prose, dialog body |
body |
14 / 20 | 400 | 0 | Default. UI text, table cells, form values |
label |
13 / 16 | 500 | 0 | Field labels, table headers, buttons, tabs |
caption |
12 / 16 | 400 | 0 | Metadata, timestamps, helper text |
overline |
11 / 16 | 600 | +0.06em | Uppercase eyebrows. Sparingly — see below |
Figures#
Numbers have different optical needs, so they get their own scale. All of these are Inter, all tabular-nums, all -0.01em tracking (tabular figures run slightly wide, and a touch of negative tracking restores the rhythm).
| Token | Size / Line | Weight | Use |
|---|---|---|---|
figure-hero |
40 / 48 | 500 | The single most important number on a page |
figure-lg |
28 / 32 | 500 | KPI card primary value |
figure-md |
20 / 28 | 500 | Card values, statement totals |
figure-sm |
14 / 20 | 400 | Table cells, ledger rows, statement lines — the workhorse |
figure-xs |
12 / 16 | 400 | Dense/compact mode, chart labels, sub-values |
Figures use weight 500 and text uses 400. A figure at the same weight as its label loses to the label, which usually has more glyphs.
Weights — only three#
400 Regular body, figures in tables, prose
500 Medium labels, buttons, headings, prominent figures
600 Semibold emphasis within a heading, the one overline, active nav
No 300. No 700+. Inter at 300 fails contrast at 13–14px on a light background, and Inter at 700 in a dense table is shouting. If you need more emphasis than 600 gives, change size or color, not weight.
Tracking#
Leave it alone. The scale already includes the only two adjustments the system allows:
- Negative tracking on large sizes (≥ 24px), because Inter's default spacing is tuned for text sizes.
- Positive tracking on
overlineonly, because uppercase always needs it.
Everything else is 0. tracking-tight on body text is one of the clearest signs of AI-generated UI.
The 3×3 rule#
Max 3 font sizes and max 3 font weights per component.
Per component, not per page. For example:
BalanceCard
eyebrow "Operating account" label 13/16 w500 text-secondary
figure "$248,190.24" hero 40/48 w500 text-primary
delta "+$12,400 (5.3%) MTD" caption 12/16 w400 positive
─────────────────────
3 sizes, 2 weights ✓
Three sizes, two weights
To add a fourth line ("last synced 2m ago"), reuse caption/400 or remove something. Reusing is almost always right. It becomes a metadata row, a fourth slot at an existing rank, which is fine. A fourth rank is not.
Hierarchy is relative. Three levels (big, medium, small) are unambiguous. Five levels make the eye compare, and comparing is slower than recognizing.
OpenType features for money#
These are font-feature-settings. They cost nothing, and almost no product ships them.
.numeric {
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum" 1, "case" 1;
letter-spacing: -0.01em;
}
tnum — tabular figures#
Every digit gets the same advance width, so decimal points align down a column and a value doesn't reflow as it changes. Required on every number in the system, tables or not. A live balance that shifts sideways as its digits change is the most distracting thing a finance UI can do.
0 and 1 differ most between proportional and tabular. If a column looks slightly off, tnum isn't on.
case — case-sensitive forms#
Almost nobody ships this, and it's a big part of why this system exists.
case raises parentheses, brackets, hyphens and dashes to align with lining figures instead of lowercase text. For accounting negatives:
without case: (1,234.00) ← parens sit low, centered on x-height
with case: (1,234.00) ← parens centered on the figures
At 14px it's a sub-pixel shift on one glyph. Down a column of forty negatives, it separates a statement from a spreadsheet dump. Apply case to every element that can contain a parenthesized negative or a minus sign.
It also fixes the minus sign. − (U+2212) with case sits at the vertical center of the figures; a hyphen-minus - never does. Use the real character.
case: the parentheses and the minus ride on the figures
zero — slashed zero#
Off for monetary values. On for account numbers, routing numbers, reference IDs, and anything a person will transcribe.
.reference { font-feature-settings: "zero" 1, "ss02" 1; }
ss02 is Inter's disambiguation set. It separates 1 / l / I and 0 / O: right for an IBAN, wrong for a balance.
frac — fractions#
Off. If you're showing fractions in a finance UI, use decimals.
Feature summary by context#
| Context | Features |
|---|---|
| Monetary value | "tnum" 1, "case" 1 |
| Percentage, ratio, count | "tnum" 1, "case" 1 |
| Account / routing / reference number | "tnum" 1, "zero" 1, "ss02" 1 |
| Body prose | none (default proportional figures read better in a sentence) |
| Chart axis labels | "tnum" 1 |
| Dates | "tnum" 1 |
Inside a sentence, use proportional figures. "We processed 1,284 payments" reads better that way. Tabular figures are for alignment, and a sentence has nothing to align to.
The currency symbol#
Set the symbol lighter than the value. Stripe and Mercury both landed on this, because the symbol is a unit label, not data.
<span className="text-tertiary font-normal">$</span>
<span className="text-primary">248,190</span>
<span className="text-tertiary">.24</span>
That snippet makes three decisions:
- Symbol at
--text-tertiary. Present, legible, subordinate. - Cents at
--text-tertiaryfor large display figures only (figure-hero,figure-lg). At 40px, a full-weight.24competes with the dollars for no reason. In tables (figure-smand below), cents stay full color: at 14px reduced contrast hurts legibility, and alignment already does the work. - 4px gap between symbol and figure. Never a typed space, which has a proportional advance and breaks tabular alignment.
Never superscript the cents. It's a retail-pricing device that reads as a discount, the wrong register for a finance product.
Symbol placement follows the locale via Intl.NumberFormat, not a hard-coded prefix. See numerics.
Text behavior#
From baseline-ui. Each is a one-line fix that almost nothing ships:
text-wrap: balanceon every heading. Prevents a one-word orphan on a card title. Always right for ≤ 4 lines.text-wrap: prettyon every paragraph. Prevents orphans and improves the rag.tabular-numson all data. Repeated here because it's the one most often missed.truncateorline-clamp-Nin dense UI, with the full value in atitleattribute and an accessible tooltip. A truncated merchant name you can't expand is a dead end.- Never truncate a number. See numerics for what to do instead.
hyphens: noneon labels and values. Hyphenating "Accounts Receivable" is worse than wrapping it.overflow-wrap: anywhereon user-generated strings. Merchant names, memo lines and email addresses will otherwise blow out a table cell.
The overline problem#
overline (11px uppercase, +0.06em) is in the scale because an eyebrow above a hero figure is useful. It's also the most abused style in dashboard design.
Rules: at most one per card and three per screen, never longer than three words, and never on a table header. Use label at --text-secondary there instead; uppercase headers in a dense table are noise.
Loading fonts#
@font-face {
font-family: "Inter Variable";
src: url("/fonts/InterVariable.woff2") format("woff2-variations");
font-weight: 100 900;
font-display: swap;
font-named-instance: "Regular";
}
font-display: swapwith a metric-matched fallback so the layout doesn't shift. Inter's metrics are close enough tosystem-uithat CLS is negligible once tuned. Measure it, then setsize-adjust/ascent-overrideon an@font-facefallback declaration.- Load exactly two faces. Inter (normal + italic,
opszaxis) and Geist Mono at 400/500, the two weights figures use. Anything else on the wire is a third family through the back door. - Subset aggressively. Latin + the currency block (U+20A0–20BF) + arrows + the math minus (U+2212). Full Inter Variable is ~350KB; a proper subset is under 40KB.
- Self-host. No third-party font CDN in a financial product. It leaks data and adds an availability dependency.