Typography
Overview
Typography defines how text looks and reads across the system: the font families, the size ramp, weights, line-heights, and letter-spacing. It commits to one typeface — Geist (with Geist Mono for code) — and keeps a native font stack as a fallback.
Every value maps 1:1 to Tailwind's text-* scale, so tokens and utilities never disagree.
Following Tailwind and shadcn, the model is per-property tokens composed at the call site —
there's no .text-h1 class. Common text roles live as a recipe table (see Text roles)
that also maps to Figma text styles.
Two layers, same tiering as Scale and Color:
- Primitives — the raw ramps (
--font-size-*,--font-weight-*,--leading-*,--tracking-*) intokens/primitives.css. - Semantic — the font families (
--font-sans,--font-mono) intokens/semantic.css. Families have no ramp, so they're defined directly here — re-point them to a brand face in one line.
Principles
- Accessible by default. 16px (
base) is the floor for reading text. Body line-height is pinned at 1.5 (WCAG 1.4.12). Weights below 400 are excluded. The 10px size is fenced to decorative use. - Semantic over literal.
xs/lg/xldescribe appearance, so they stay at the primitive tier. Intent lives in the recipe table and Figma styles. - Tailwind-aligned. Sizes, weights, leadings, and trackings all equal a Tailwind default
utility —
--font-size-xlistext-xl. - Restraint. Four weights, four leadings, three trackings; the size ramp stops at
5xl; no bespoke class API.
Tokens
Font families (semantic)
| Token | Stack | Use for |
|---|---|---|
--font-sans | Geist → system UI stack | All UI text |
--font-mono | Geist Mono → system mono stack | Code, tabular figures |
Size ramp (primitives)
rem-based; px is a reading aid. Base = 16px.
| Token | rem | px | Typical role |
|---|---|---|---|
--font-size-2xs | 0.625 | 10 | Overline — decorative only |
--font-size-xs | 0.75 | 12 | Captions, metadata |
--font-size-sm | 0.875 | 14 | UI labels, secondary text |
--font-size-base | 1 | 16 | Body — the anchor |
--font-size-lg | 1.125 | 18 | Lead paragraph |
--font-size-xl | 1.25 | 20 | Small heading |
--font-size-2xl | 1.5 | 24 | h3 |
--font-size-3xl | 1.875 | 30 | h2 |
--font-size-4xl | 2.25 | 36 | h1 |
--font-size-5xl | 3 | 48 | Display |
Weights (primitives)
| Token | Value | Role |
|---|---|---|
--font-weight-normal | 400 | Body / base |
--font-weight-medium | 500 | Buttons, UI labels |
--font-weight-semibold | 600 | Headings |
--font-weight-bold | 700 | Displays, strong emphasis |
Line-height (primitives)
Unitless, so the ratio scales with any size.
| Token | Value | Use for |
|---|---|---|
--leading-tight | 1.2 | Displays & large headings |
--leading-snug | 1.375 | Small headings, short UI text |
--leading-normal | 1.5 | Body (default) |
--leading-relaxed | 1.625 | Long-form reading |
Letter-spacing (primitives)
em-based, so it scales with size.
| Token | Value | Use for |
|---|---|---|
--tracking-tight | -0.025em | Large headings |
--tracking-normal | 0 | Everything (default) |
--tracking-wide | 0.05em | 2xs uppercase overline |
Text roles
Compose these in markup — this table is the reference, and maps 1:1 to Figma text styles.
| Role | Tailwind utilities | size / weight / tracking / leading |
|---|---|---|
| Display | text-5xl font-bold tracking-tight leading-tight | 48 / 700 / -0.025em / 1.2 |
| Heading 1 | text-4xl font-semibold tracking-tight leading-tight | 36 / 600 / -0.025em / 1.2 |
| Heading 2 | text-3xl font-semibold tracking-tight leading-snug | 30 / 600 / -0.025em / 1.375 |
| Heading 3 | text-2xl font-semibold leading-snug | 24 / 600 / 0 / 1.375 |
| Heading 4 | text-xl font-semibold leading-snug | 20 / 600 / 0 / 1.375 |
| Lead | text-lg leading-relaxed | 18 / 400 / 0 / 1.625 |
| Body | text-base leading-normal | 16 / 400 / 0 / 1.5 |
| Body small | text-sm leading-normal | 14 / 400 / 0 / 1.5 |
| Label / button | text-sm font-medium leading-snug | 14 / 500 / 0 / 1.375 |
| Caption | text-xs leading-snug | 12 / 400 / 0 / 1.375 |
| Overline | text-2xs font-medium uppercase tracking-wide | 10 / 500 / +0.05em |
| Code | font-mono text-sm | Geist Mono / 14 |
Rationale
Why commit to Geist (not a system stack)? A single typeface gives consistent rendering across
operating systems, and Geist is clean, modern, and ships a matched monospace. The system stack
stays as a fallback so text renders correctly before the webfont loads. --font-sans is still
re-pointable — swap in a brand face in one line, like --primary.
Why a separate ramp, not the 4px master scale? Type sizes want to be multiplicative (each step proportional to the last) so hierarchy holds across screens; spacing wants to be linear (snap to a 4px grid). Different jobs, so type gets its own honest ladder. The master scale still owns all spacing around text.
Why 16px as the floor? Smaller text fails readability for primary content. 14px is for
secondary UI only; 12px for captions; 10px (2xs) for decorative micro-labels — never body.
Why body line-height 1.5? WCAG 1.4.12 requires ≥1.5× for body text. Unitless keeps the ratio correct at every size (16px→24px, 18px→27px) and avoids a fixed value clipping on larger text.
Why per-property tokens, not .text-h1 classes? It's how Tailwind and shadcn work — hierarchy
composes at the call site. A bespoke class API is a parallel surface Tailwind already makes
unnecessary. If a recipe gets repeated everywhere, add a class then (not before).
Do / Don’t
Do
- Use
--font-size-base(16px) or larger for anything read as content. - Keep body at
--leading-normal(1.5) — it's a WCAG requirement, not a preference. - Compose text roles from the recipe table; keep them in sync with Figma text styles.
- Reach for
--font-monofor code and tabular figures.
Don’t
- Use
2xs(10px) for body, or for anything a user must actually read. - Hard-code font sizes (
15px) — pick the nearest ramp step. - Invent a weight below 400 or above 700, or add a size past
5xlwithout a real, repeated need. - Build a
.text-h1class layer — compose utilities instead (Restraint).