Skip to content

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 overline only, 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:

  1. Symbol at --text-tertiary. Present, legible, subordinate.
  2. Cents at --text-tertiary for large display figures only (figure-hero, figure-lg). At 40px, .24 at full weight competes with the dollars for no reason. In tables — figure-sm and below — cents stay at full color, because at 14px the reduced contrast hurts legibility and the alignment already does the work.
  3. 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: balance on every heading. Prevents a one-word orphan line on a card title. Cheap, always right for ≤ 4 lines.
  • text-wrap: pretty on every paragraph. Prevents orphans and improves rag.
  • tabular-nums on all data. Restated because it's the most-missed one.
  • truncate or line-clamp-N in dense UI, with the full value in a title attribute 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: none on labels and values. Hyphenating "Accounts Receivable" is worse than wrapping it.
  • overflow-wrap: anywhere on 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: swap with a metric-matched fallback so the layout doesn't shift. Inter's metrics are close enough to system-ui that CLS is negligible with size-adjust tuning; measure it and set size-adjust/ascent-override on an @font-face fallback declaration.
  • Load exactly two faces. Inter (normal + italic, opsz axis) 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.

Told when this spec changes

The docs are the contract the components are built against. When one changes, the component changes with it — and the copy in your repo does not. One email when that happens.

One address, no list rental, unsubscribe in one click.

Works in your QuickBooks Online or Xero file

Get your books closed every month

Lopifi is building AI bookkeeping that keeps your books closed on US GAAP as transactions happen, with a CPA review every quarter, for $200 a month. We’re not open yet.

$200/month when we open. No card now.