---
title: "Typography"
description: "Two families, and at most three sizes and three weights per component. The limit forces hierarchy to come from contrast instead of variety."
canonical: "https://lopifi.com/docs/foundations/typography"
---

# Typography

> Two families, and at most three sizes and three weights per component. The limit forces hierarchy to come from contrast instead of variety.

Source: https://lopifi.com/docs/foundations/typography
Part of: Lopifi — Planned: US GAAP books on autopilot, checked by a CPA (https://lopifi.com)

---

# 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.

```css
--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.

```css
--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.

<!-- plate:type-scale -->

### 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 `overline` only, 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 ✓
```

<!-- plate:type-three-by-three -->

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.

```css
.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.

<!-- plate:type-case -->

### `zero` — slashed zero
**Off** for monetary values. On for account numbers, routing numbers, reference IDs, and anything a person will transcribe.

```css
.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.

```tsx
<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:

1. **Symbol at `--text-tertiary`.** Present, legible, subordinate.
2. **Cents at `--text-tertiary` for large display figures only** (`figure-hero`, `figure-lg`). At 40px, a full-weight `.24` competes with the dollars for no reason. In tables (`figure-sm` and below), cents stay full color: at 14px reduced contrast hurts legibility, and alignment already does the work.
3. **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](https://lopifi.com/md/docs/foundations/numerics).

---

## Text behavior

From baseline-ui. Each is a one-line fix that almost nothing ships:

- **`text-wrap: balance` on every heading.** Prevents a one-word orphan on a card title. Always right for ≤ 4 lines.
- **`text-wrap: pretty` on every paragraph.** Prevents orphans and improves the rag.
- **`tabular-nums` on all data.** Repeated here because it's the one most often missed.
- **`truncate` or `line-clamp-N` in dense UI**, with the full value in a `title` attribute *and* an accessible tooltip. A truncated merchant name you can't expand is a dead end.
- **Never truncate a number.** See [numerics](https://lopifi.com/md/docs/foundations/numerics#overflow) 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 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

```css
@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 once tuned. Measure it, then 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 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.
