Typography
Overview
Ready-to-use type systems (Product UI, Editorial, Marketing, Docs/Technical) plus the methodology to size text, build scales, set vertical rhythm, and pick fonts — so generated UI is readable instead of tiny, thin, and low-contrast.
Core principle: size text by role on a scale, never by eyeballed pixels. Pick a base (16px), a ratio (~1.2), and derive every size from it — then keep every role above the readability floor, because most "impossible to read" output violates at least one of its four numbers.
The readability floor (the contract)
These are the recede-defaults. Meet all four and text is legible by construction; deviate only deliberately and name the reason (a 14px compact scale for genuine enterprise density is a legitimate reason — "it looked better" is not).
| Floor |
Default |
Why |
| Size |
body ≥ 16px / 1rem |
Browser default; iOS zooms <input> under 16px. Captions never < 14px — the hard floor for any text. |
| Weight |
body ≥ 400 |
Weights 100–300 thin the stroke and drop perceived contrast on small text. |
| Contrast |
text ≥ 4.5:1 (large ≥ 3:1) |
WCAG 2 AA. Re-check dark mode / thin fonts with APCA — WCAG2 overstates contrast near black. |
| Line-height |
body ≥ 1.5 |
Cramped leading hurts reading and fails the WCAG 1.4.12 spacing override. |
The single worst combination is small + thin + low-contrast — any two is risky, all three is the canonical unreadable panel.
Verify computed, not authored. Reading the class, prop, or token in source only tells you intent — check the rendered value (devtools Computed panel, or getComputedStyle(el).fontSize) before calling a size fixed. A "should be 16px" edit that still renders 13px is a common failure mode, not a rare one.
Navigation
| Load this |
When |
| references/systems.md |
Copying a ready-made system — full token tables (size · line-height · weight · tracking) for all four |
| references/scale.md |
Building a custom scale, vertical rhythm, or measure from scratch |
| references/readability.md |
Verifying readability — minimum sizes, WCAG/APCA, fluid clamp(), known failure modes |
| references/fonts.md |
Choosing, pairing, styling, or loading typefaces |
| references/EVAL.md |
Re-tuning this skill's own description |
Load one reference only when the task needs it — keep context lean.
Type System Library
Pick one, then copy its token table from references/systems.md. When the product type is ambiguous, default to Product UI.
- Product UI — system sans, 16px base, ratio 1.2. Dense dashboards, apps, dev tools. Tabular numerals for data. A 14px "compact" variant for enterprise density.
- Editorial / Long-form — serif body at 18px / LH 1.6, ratio 1.25, measure 66ch. Articles, blogs, docs prose. Space-before > space-after on headings.
- Marketing / Landing — fluid
clamp() display, ratio 1.333, tight tracking at scale. Heroes, landing pages. Bigger body (18px), scrim on text-over-image.
- Docs / Technical — Product UI scale + first-class mono (code blocks, inline code, API tables). Ligatures off in code, slashed-zero, tabular numerals.
Methodology (essentials)
- Build the scale from a base × ratio.
size(n) = base × ratio^n. Dense UI → 1.125–1.2; product/general → 1.2–1.25; editorial → 1.333–1.414; hero/display → 1.5–1.618. Round to whole/half px, expose as rem.
- Line-height scales inversely with size. Body 1.5; headings 1.2; display 1.0–1.1. The bigger the text, the tighter the leading. Keep
line-height unitless so it recomputes per element instead of inheriting a fixed length.
- Vertical rhythm on an 8px grid. Rhythm unit = body size × body LH = 24px. Make every margin/padding a multiple of 4/8px. Enforce a consistent spacing scale rather than pixel-chasing a literal baseline grid — sub-pixel rounding and nested padding break literal grids anyway.
- Cap the measure. Body
max-width: ~66ch (≈ 45–75 characters/line). Too-long lines cause return-sweep errors; too-short break rhythm.
- Tracking is optical. 0 on body; −0.01 to −0.025em on large headings (tighten as size grows); +0.05 to +0.12em on ALL-CAPS / small labels. Set in
em so it scales with size.
- Weight carries hierarchy. Body 400, headings 600 (700 for stronger). Large display can go lighter (400) because mass substitutes for weight. Reach for one good family with multiple weights before adding a second.
- Size in
rem, fluid via clamp(). clamp(min_rem, calc(rem + vw), max_rem) — keep both bounds in rem and mix a rem term into the preferred value, because browsers don't scale vw on zoom and a vw-only size fails WCAG 1.4.4.
Examples
Body text sizing
✅ Desired
body { font-size: 1rem; line-height: 1.5; font-weight: 400; color: #1e293b; } /* 16px, ~14:1 on white */
❌ Anti-pattern
body { font-size: 13px; line-height: 1.25; font-weight: 300; color: #9ca3af; } /* small + thin + low-contrast (≈2.5:1), and px ignores the user's font-size setting */
Fluid heading
✅ Desired
h1 { font-size: clamp(1.75rem, 1.1rem + 3.2vw, 3rem); line-height: 1.1; letter-spacing: -0.02em; } /* scales, caps, still zooms */
❌ Anti-pattern
h1 { font-size: 4vw; line-height: 1.1; } /* vw-only: does not scale on zoom, fails WCAG 1.4.4; no cap on wide screens */
Why it works: rem bounds plus a rem-anchored preferred value keep it readable at 200% zoom, and the cap stops a runaway hero size.
Table header and caption
✅ Desired
th { font-size: 1rem; line-height: 1.5; font-weight: 600; }
figcaption { font-size: 0.875rem; line-height: 1.45; color: #5b6b7d; } /* 14px floor, 4.9:1 */
❌ Anti-pattern
th { font-size: 0.75rem; font-weight: 500; color: #9ca3af; } /* 12px is under the 14px hard floor; #9ca3af on white ≈2.5:1 — chrome is where the floor breaks first */
Gotchas
- Symptom: Headings size right but feel cramped or loose. Cause: one fixed line-height everywhere. Fix: line-height inverse to size — body 1.5, headings 1.2, display ~1.05.
- Symptom: Inputs zoom/jump on iPhone. Cause: form-control text < 16px. Fix: inputs ≥16px; never
user-scalable=no (it disables pinch-zoom and fails WCAG).
- Symptom: Long paragraphs are tiring to read. Cause: lines run 100+ characters. Fix:
max-width: 66ch on body/prose containers.
- Symptom: Heading floats between two sections. Cause: equal margin above and below. Fix: space-before > space-after, binding the heading to the text under it.
- Symptom: Numbers in a table shift width as they change. Cause: proportional figures. Fix:
font-variant-numeric: tabular-nums.
- Symptom: Fluid type stops scaling at 200% zoom. Cause:
vw-only font-size. Fix: clamp(rem, calc(rem + vw), rem); browsers don't zoom vw.
- Symptom:
line-height: clamp(1.3, 0.9rem + 0.4vw, 1.6) silently does nothing. Cause: clamp() can't mix <number> bounds with a <length> preferred value, so the declaration is dropped. Fix: keep the unitless ratio and step it at a breakpoint instead of making leading fluid.
- Symptom: Text over a hero image is unreadable in spots. Cause: no scrim; contrast varies per pixel. Fix: add a semi-opaque overlay or
text-shadow, and verify the worst-case region.
- Symptom: Bold looks smeared, italics weak. Cause: faux-synthesized weight/style the font file lacks. Fix: load real weights or a variable font;
font-synthesis: none exposes the gaps.
- Symptom: The theme says text should be ≥16px, but it renders small anyway. Cause: size-token names lie — Mantine
size="sm"/"xs" and Tailwind text-sm/text-xs all compute to 14px/12px, under the floor; nested em units also compound multiplicatively as components nest. Fix: check the computed size (see "Verify computed, not authored" above) and prefer rem for font-size so it can't compound.
- Symptom: Body text passes the floor but chart text is still tiny and washed out. Cause: charting libraries (Recharts, Mantine charts) render SVG text with their own inline
font-size/fill, which never inherits your type scale or color tokens. Fix: target the library's text elements directly (e.g. .recharts-wrapper text { font-size: …; fill: var(--text) }) and re-verify computed size and color.
- Symptom: Every heading — often all body text too — renders in the browser's fallback serif, even though the fonts are imported and the theme references them correctly; source review finds nothing wrong. Cause:
next/font in variable mode combined with a UI library that injects its font-family at :root (Mantine does: :root, :host { --mantine-font-family: var(--font-body) }). If the font's .variable class is applied only to <body>, --font-body doesn't exist at :root — custom properties never inherit upward — so every var() reference is invalid and falls back. It only shows up in getComputedStyle(document.body).fontFamily. Fix: apply the .variable class to <html>, or follow the library's own documented pattern where one exists (Mantine injects the resolved font.style.fontFamily string).
- Symptom: Body text passes the floor everywhere you checked, but tables, nav labels, tooltips, menus, and form-field labels are still ~14px — with no
size prop anywhere to explain it. Cause: component libraries ship individual components below their own base — Mantine's Table cell, NavLink label, Tooltip, Menu item, Alert, Notification, Tabs tab, and Input label/description all default to sm (14px) or xs (12px) while Text defaults to md (16px). There's no authored prop to grep for. Fix: override the whole size scale explicitly in the shared theme, and spot-check components you never sized rather than only the ones you did.
Integration
- color-system — supplies the text/background hexes and verifies contrast; this skill sets size, weight, and rhythm. Pick colors there, size type here.
- frontend-design — builds the components and layout; this skill decides the type. Pick a system here, implement the UI there.
1---2name: typography3description: Use this skill whenever the user wants typography work — sizing text, building a type scale, setting line-height or vertical rhythm, fixing unreadable type, or picking and pairing fonts. Triggers include "what font size should I use", "set up a type scale", "this text is too small to read", "what line-height for paragraphs", or "pair a heading font with a body font" — even if the user never says "typography" and only describes text that looks cramped, thin, or washed out. Do NOT use this skill for choosing a color palette or checking a color's contrast ratio — see color-system; or for page layout and component structure — see frontend-design.4license: MIT5---67# Typography89## Overview1011Ready-to-use **type systems** (Product UI, Editorial, Marketing, Docs/Technical) plus the methodology to size text, build scales, set vertical rhythm, and pick fonts — so generated UI is readable instead of tiny, thin, and low-contrast.1213**Core principle:** size text by **role on a scale**, never by eyeballed pixels. Pick a base (16px), a ratio (~1.2), and derive every size from it — then keep every role above the readability floor, because most "impossible to read" output violates at least one of its four numbers.1415## The readability floor (the contract)1617These are the recede-defaults. Meet all four and text is legible by construction; deviate only deliberately and name the reason (a 14px compact scale for genuine enterprise density is a legitimate reason — "it looked better" is not).1819| Floor | Default | Why |20|---|---|---|21| **Size** | body **≥ 16px / 1rem** | Browser default; iOS zooms `<input>` under 16px. Captions never < 14px — the hard floor for any text. |22| **Weight** | body **≥ 400** | Weights 100–300 thin the stroke and drop perceived contrast on small text. |23| **Contrast** | text **≥ 4.5:1** (large ≥ 3:1) | WCAG 2 AA. Re-check dark mode / thin fonts with APCA — WCAG2 overstates contrast near black. |24| **Line-height** | body **≥ 1.5** | Cramped leading hurts reading and fails the WCAG 1.4.12 spacing override. |2526The single worst combination is **small + thin + low-contrast** — any two is risky, all three is the canonical unreadable panel.2728**Verify computed, not authored.** Reading the class, prop, or token in source only tells you intent — check the rendered value (devtools Computed panel, or `getComputedStyle(el).fontSize`) before calling a size fixed. A "should be 16px" edit that still renders 13px is a common failure mode, not a rare one.2930## Navigation3132| Load this | When |33|---|---|34| [references/systems.md](references/systems.md) | Copying a ready-made system — full token tables (size · line-height · weight · tracking) for all four |35| [references/scale.md](references/scale.md) | Building a custom scale, vertical rhythm, or measure from scratch |36| [references/readability.md](references/readability.md) | Verifying readability — minimum sizes, WCAG/APCA, fluid `clamp()`, known failure modes |37| [references/fonts.md](references/fonts.md) | Choosing, pairing, styling, or loading typefaces |38| [references/EVAL.md](references/EVAL.md) | Re-tuning this skill's own description |3940Load one reference only when the task needs it — keep context lean.4142## Type System Library4344Pick one, then copy its token table from [references/systems.md](references/systems.md). When the product type is ambiguous, default to **Product UI**.4546- **Product UI** — system sans, 16px base, ratio **1.2**. Dense dashboards, apps, dev tools. Tabular numerals for data. A 14px "compact" variant for enterprise density.47- **Editorial / Long-form** — serif body at **18px / LH 1.6**, ratio **1.25**, measure **66ch**. Articles, blogs, docs prose. Space-before > space-after on headings.48- **Marketing / Landing** — fluid `clamp()` display, ratio **1.333**, tight tracking at scale. Heroes, landing pages. Bigger body (18px), scrim on text-over-image.49- **Docs / Technical** — Product UI scale + first-class **mono** (code blocks, inline code, API tables). Ligatures off in code, slashed-zero, tabular numerals.5051## Methodology (essentials)5253- **Build the scale from a base × ratio.** `size(n) = base × ratio^n`. Dense UI → 1.125–1.2; product/general → 1.2–1.25; editorial → 1.333–1.414; hero/display → 1.5–1.618. Round to whole/half px, expose as `rem`.54- **Line-height scales inversely with size.** Body 1.5; headings 1.2; display 1.0–1.1. The bigger the text, the tighter the leading. Keep `line-height` **unitless** so it recomputes per element instead of inheriting a fixed length.55- **Vertical rhythm on an 8px grid.** Rhythm unit = body size × body LH = 24px. Make every margin/padding a multiple of 4/8px. Enforce a consistent spacing scale rather than pixel-chasing a literal baseline grid — sub-pixel rounding and nested padding break literal grids anyway.56- **Cap the measure.** Body `max-width: ~66ch` (≈ 45–75 characters/line). Too-long lines cause return-sweep errors; too-short break rhythm.57- **Tracking is optical.** 0 on body; **−0.01 to −0.025em** on large headings (tighten as size grows); **+0.05 to +0.12em** on ALL-CAPS / small labels. Set in `em` so it scales with size.58- **Weight carries hierarchy.** Body 400, headings 600 (700 for stronger). Large display can go lighter (400) because mass substitutes for weight. Reach for one good family with multiple weights before adding a second.59- **Size in `rem`, fluid via `clamp()`.** `clamp(min_rem, calc(rem + vw), max_rem)` — keep both bounds in `rem` and mix a `rem` term into the preferred value, because browsers don't scale `vw` on zoom and a `vw`-only size fails WCAG 1.4.4.6061## Examples6263### Body text sizing6465✅ Desired6667```css68body { font-size: 1rem; line-height: 1.5; font-weight: 400; color: #1e293b; } /* 16px, ~14:1 on white */69```7071❌ Anti-pattern7273```css74body { font-size: 13px; line-height: 1.25; font-weight: 300; color: #9ca3af; } /* small + thin + low-contrast (≈2.5:1), and px ignores the user's font-size setting */75```7677### Fluid heading7879✅ Desired8081```css82h1 { font-size: clamp(1.75rem, 1.1rem + 3.2vw, 3rem); line-height: 1.1; letter-spacing: -0.02em; } /* scales, caps, still zooms */83```8485❌ Anti-pattern8687```css88h1 { font-size: 4vw; line-height: 1.1; } /* vw-only: does not scale on zoom, fails WCAG 1.4.4; no cap on wide screens */89```9091Why it works: `rem` bounds plus a `rem`-anchored preferred value keep it readable at 200% zoom, and the cap stops a runaway hero size.9293### Table header and caption9495✅ Desired9697```css98th { font-size: 1rem; line-height: 1.5; font-weight: 600; }99figcaption { font-size: 0.875rem; line-height: 1.45; color: #5b6b7d; } /* 14px floor, 4.9:1 */100```101102❌ Anti-pattern103104```css105th { font-size: 0.75rem; font-weight: 500; color: #9ca3af; } /* 12px is under the 14px hard floor; #9ca3af on white ≈2.5:1 — chrome is where the floor breaks first */106```107108## Gotchas109110- **Symptom:** Headings size right but feel cramped or loose. **Cause:** one fixed line-height everywhere. **Fix:** line-height *inverse* to size — body 1.5, headings 1.2, display ~1.05.111- **Symptom:** Inputs zoom/jump on iPhone. **Cause:** form-control text < 16px. **Fix:** inputs ≥16px; never `user-scalable=no` (it disables pinch-zoom and fails WCAG).112- **Symptom:** Long paragraphs are tiring to read. **Cause:** lines run 100+ characters. **Fix:** `max-width: 66ch` on body/prose containers.113- **Symptom:** Heading floats between two sections. **Cause:** equal margin above and below. **Fix:** space-before > space-after, binding the heading to the text under it.114- **Symptom:** Numbers in a table shift width as they change. **Cause:** proportional figures. **Fix:** `font-variant-numeric: tabular-nums`.115- **Symptom:** Fluid type stops scaling at 200% zoom. **Cause:** `vw`-only `font-size`. **Fix:** `clamp(rem, calc(rem + vw), rem)`; browsers don't zoom `vw`.116- **Symptom:** `line-height: clamp(1.3, 0.9rem + 0.4vw, 1.6)` silently does nothing. **Cause:** `clamp()` can't mix `<number>` bounds with a `<length>` preferred value, so the declaration is dropped. **Fix:** keep the unitless ratio and step it at a breakpoint instead of making leading fluid.117- **Symptom:** Text over a hero image is unreadable in spots. **Cause:** no scrim; contrast varies per pixel. **Fix:** add a semi-opaque overlay or `text-shadow`, and verify the worst-case region.118- **Symptom:** Bold looks smeared, italics weak. **Cause:** faux-synthesized weight/style the font file lacks. **Fix:** load real weights or a variable font; `font-synthesis: none` exposes the gaps.119- **Symptom:** The theme says text should be ≥16px, but it renders small anyway. **Cause:** size-token names lie — Mantine `size="sm"`/`"xs"` and Tailwind `text-sm`/`text-xs` all compute to 14px/12px, under the floor; nested `em` units also compound multiplicatively as components nest. **Fix:** check the computed size (see "Verify computed, not authored" above) and prefer `rem` for font-size so it can't compound.120- **Symptom:** Body text passes the floor but chart text is still tiny and washed out. **Cause:** charting libraries (Recharts, Mantine charts) render SVG text with their own inline `font-size`/`fill`, which never inherits your type scale or color tokens. **Fix:** target the library's text elements directly (e.g. `.recharts-wrapper text { font-size: …; fill: var(--text) }`) and re-verify computed size and color.121- **Symptom:** Every heading — often all body text too — renders in the browser's fallback serif, even though the fonts are imported and the theme references them correctly; source review finds nothing wrong. **Cause:** `next/font` in `variable` mode combined with a UI library that injects its font-family at `:root` (Mantine does: `:root, :host { --mantine-font-family: var(--font-body) }`). If the font's `.variable` class is applied only to `<body>`, `--font-body` doesn't exist at `:root` — custom properties never inherit upward — so every `var()` reference is invalid and falls back. It only shows up in `getComputedStyle(document.body).fontFamily`. **Fix:** apply the `.variable` class to `<html>`, or follow the library's own documented pattern where one exists ([Mantine injects the resolved `font.style.fontFamily` string](https://help.mantine.dev/q/next-load-fonts)).122- **Symptom:** Body text passes the floor everywhere you checked, but tables, nav labels, tooltips, menus, and form-field labels are still ~14px — with no `size` prop anywhere to explain it. **Cause:** component libraries ship individual components below their own base — Mantine's `Table` cell, `NavLink` label, `Tooltip`, `Menu` item, `Alert`, `Notification`, `Tabs` tab, and `Input` label/description all default to `sm` (14px) or `xs` (12px) while `Text` defaults to `md` (16px). There's no authored prop to grep for. **Fix:** override the whole size scale explicitly in the shared theme, and spot-check components you never sized rather than only the ones you did.123124## Integration125126- **color-system** — supplies the text/background hexes and verifies contrast; this skill sets size, weight, and rhythm. Pick colors there, size type here.127- **frontend-design** — builds the components and layout; this skill decides the type. Pick a system here, implement the UI there.