Typography
Two families. Three sizes and three weights per component. That constraint is not minimalism for its own sake — it is what forces hierarchy to come from contrast rather than from variety.
The families
Inter — every word
Inter Variable, the interface face: labels, prose, headings, buttons, display sizes, and the landing accent (its true italic). Display is Inter at display sizes — tighter tracking, the optical-size axis doing the rest — not a second text family.
Why Inter: a variable optical-size axis, case-sensitive forms (which we depend on — see below), excellent hinting at 13–14px, and character-level disambiguation (cv05, cv08) that matters when someone is reading 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
The figure face. Every amount, rate, ratio and reference number resolves to --font-numeric and to nothing else — and so do code, keys and identifiers, so the system still renders in 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 still carry the proportional rhythm of the text around them. In a 40-column grid the reader scans shapes down a column, not left to right. Geist Mono sits on a fixed advance with an unambiguous 1/7 and a zero that never reads as an O, so a column forms a hard vertical edge and a mistyped digit shows as a shape error before it is read as a number. Its digits sit optically large against 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 rather than 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, it's the figure face.
No third family
No serif, no second mono, no icon font. A consumer who wants figures in Inter sets --font-numeric: var(--font-sans) and every component follows, because none of them name a family directly.
The scale
Sizes are chosen for legibility; line-heights are chosen to land on the 8px grid. That's what keeps type aligned to layout.
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
A separate scale, because numbers have different optical requirements. All of these are Inter, all tabular-nums, all -0.01em tracking (tabular figures are slightly wide; a touch of negative tracking restores 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, text uses 400. A figure at the same weight as its label loses to the label, because the label 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. Inter at 700 in a dense table is shouting. Three weights is genuinely enough — if you want more emphasis than 600 gives, the answer is size or color, not weight.
Tracking
Do not touch it. The scale above already bakes in the only two adjustments the system permits:
- Negative tracking on large sizes (≥ 24px), because Inter's default spacing is set 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 markers of AI-generated UI.
The 3×3 rule
Max 3 font sizes and max 3 font weights per component.
Per component, not per page. Worked 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 ✓
Add a fourth ("last synced 2m ago") and you must reuse caption/400, or remove something. Reusing is nearly always right — it becomes a metadata row, which is a fourth slot at the same rank, which is legitimate. A fourth rank is not.
Why this works: hierarchy is relative. Three levels are unambiguous — big, medium, small. Five levels force the eye to compare, and comparison is slower than recognition.
OpenType features for money
This is where a finance system earns its name. These are font-feature-settings, they cost nothing, and essentially 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. Mandatory on every number in the system. Not just tables — a live-updating balance that shifts horizontally as digits change is the most distracting thing a finance UI can do.
Note 0 and 1 are the digits that differ most between proportional and tabular. If a column looks slightly wrong, tnum isn't on.
case — case-sensitive forms
The detail nobody ships, and the reason this system is worth building.
case raises parentheses, brackets, hyphens and dashes to align with lining figures rather than lowercase text. So 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 difference on one glyph. Down a column of forty negative numbers it's the difference between a statement and 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 - does not, ever. Use the real character.
zero — slashed zero
Off for monetary values. On for account numbers, routing numbers, reference IDs, and anything a human will transcribe.
.reference { font-feature-settings: "zero" 1, "ss02" 1; }
ss02 is Inter's disambiguation set — it separates 1 / l / I and 0 / O. Exactly right for an IBAN, wrong for a balance.
frac — fractions
Off. If you're rendering 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 |
Note the last one on prose: inside a sentence, use proportional figures. "We processed 1,284 payments" reads better proportional. Tabular is for alignment; a sentence has nothing to align to.
The currency symbol
Set the symbol lighter than the value. Stripe and Mercury converged on this independently, and the reason is structural: 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>
Three decisions in that snippet:
- Symbol at
--text-tertiary. Present, legible, subordinate. - Cents at
--text-tertiaryfor large display figures only (figure-hero,figure-lg). At 40px,.24at full weight competes with the dollars for no reason. In tables —figure-smand below — cents stay at full color, because at 14px the reduced contrast hurts legibility and the alignment already does the work. - 4px gap between symbol and figure, never a hard-coded space character (which is a proportional advance and breaks tabular alignment).
Never superscript the cents. It's a retail-pricing device that reads as a discount, which is the opposite of the register a finance product wants.
Symbol placement follows the locale via Intl.NumberFormat, not a hard-coded prefix — see numerics.
Text behavior
Per baseline-ui, and all of these are one-line fixes that almost nothing ships:
text-wrap: balanceon every heading. Prevents a one-word orphan line on a card title. Cheap, always right for ≤ 4 lines.text-wrap: prettyon every paragraph. Prevents orphans and improves rag.tabular-numson all data. Restated because it's the most-missed one.truncateorline-clamp-Nin dense UI, with the full value in atitleattribute and an accessible tooltip. A truncated merchant name with no way to see it is a dead end.- Never truncate a number. Ever. 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 eyebrow labels are genuinely useful above a hero figure. It is also the single most abused style in dashboard design.
Rules: at most one per card, at most three per screen, never on a table header (use label at --text-secondary instead — uppercase table headers in a dense table are noise), never longer than three words.
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 withsize-adjusttuning; measure it and 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 by 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's a data-leak and an availability dependency.