Typography
Generated from DESIGN_SYSTEM.md
Rules
Hierarchy through weight and size. Never use multiple font colors โ use text-primary at full contrast and text-secondary (contrast-validated, โฅ 4.5:1) for secondary/caption text. Never use opacity to drop text below the AA contrast floor โ the old text-muted 40โ50% tier is retired; captions are distinguished by size/weight, not lowered contrast.
| Role | Size / Weight | Web Utility | iOS (AppTheme.Typography) | Android | Use |
|---|---|---|---|---|---|
page-title | 22px / bold | .page-title | pageTitle (28pt bold) | headlineMedium | Screen-level headings |
section-heading | 11px / bold, tracked | .section-heading | sectionHeading (13pt semibold) | labelSmall | Section labels |
label-primary | 14px / semibold | .label-primary | labelPrimary (15pt semibold) | titleSmall | Card titles, primary labels |
label-secondary | 12px / regular | .label-secondary | labelSecondary (13pt regular) | bodySmall | Captions, secondary info |
body | 15px / regular | โ | bodyText (15pt regular) | bodyMedium | Body content |
caption | 11โ12px / regular | โ | caption (12pt regular) | labelSmall | Timestamps, metadata |
price | 30โ32px / black | .price-display | price (32pt black) | displaySmall | Pricing, large numeric display |
eyebrow | 11px / bold | .text-eyebrow | eyebrow (11pt bold) | labelSmall | Tiny overline labels |
Rules:
- iOS:
AppTheme.Typography.*for EVERY text role โ all tokens are Dynamic-Type-scaling (UIFontMetrics-anchored: design-system pt at the default setting, growing with accessibility sizes). Inline.font(.system(size:))and bare system aliases in feature code are banned. - Android: always use
MaterialTheme.typography.*โ never hardcodedspvalues. - Web: use utility classes (
page-title,section-heading,label-primary,label-secondary) โ never inlinetext-[X].
Text color hierarchy (AA-validated โ tiers by typography, never by opacity)
Text tone uses at most two readable tiers per surface, always at full opacity. On a light surface there is no readable tier below text-secondary โ #71717A already sits at the 4.5:1 floor (the lightest AA-passing gray on white is #767676). Lower visual tiers come from size / weight / case, never a lighter or more-transparent color.
That floor is measured on bg-surface (white) and holds nowhere else โ not on the ground, and not on a section-surface panel either. section-surface is zinc-100 (#F4F4F5), where the same gray is 4.40:1, also a text fail; the fix there is the same zinc-600 (7.03:1) via text-secondary-ground. The page ground bg-background is #E4E4E7 in light mode, and the same #71717A on it is 3.81:1 โ a FAIL for text. Secondary text lives inside a surface (card, panel, section-surface); a line that must sit on the bare ground uses zinc-600 (#52525B, 6.09:1 on the ground, 7.73:1 on white).
| Token | Light | Dark | On its surface | Web | iOS | Android | Use |
|---|---|---|---|---|---|---|---|
text-primary | #0A1931 | #F8FAFC | ~16:1 | text-prime | textPrimary | onSurface | Headings, body, primary content |
text-secondary | #71717A | #A1A1AA | ~4.6:1 | text-muted-foreground | textSecondary | onSurfaceVariant | Secondary, captions, metadata, labels โ the floor |
text-on-brand | #FFFFFF | #FFFFFF | ~17:1 on brand navy | text-on-brand | textOnBrand | onPrimaryContainer | Primary text on brand / dark-gradient surfaces |
text-on-brand-muted | #CBD5E1 | #CBD5E1 | ~9:1 on brand navy | text-on-brand-muted | textOnBrandMuted | โ | Secondary text on brand / dark surfaces |
Banned: dimming text with opacity below the floor โ text-muted-foreground/40, text-prime/40, text-blue-200/50, opacity-50 on text, etc. (the retired "40โ50% caption" tier).
Tier-by-typography utilities (all full-opacity text-secondary; differentiate by size/weight/case):
| Utility | Spec | Replaces |
|---|---|---|
label-primary | semibold 14px text-prime | card titles |
label-secondary | 12px text-secondary | captions, metadata |
section-heading | 14px bold tracked text-secondary | section labels |
text-eyebrow | 11px bold tracking-wide, zinc-600 / zinc-400 โ never the muted token: 4.40:1 on zinc-100 at 11px | tiny overline labels (replaces hand-rolled text-[10px] โฆ /40) |
On brand / dark surfaces (sign-in brand panel, gradient heroes): use text-on-brand / text-on-brand-muted โ never a magic color (text-blue-200) or opacity. Contrast is measured against the dark surface, where a light token passes AA.
Numeric display โ thousands separators are mandatory
Every user-facing number (prices, market cap, trading value, counts) renders with digit grouping โ 1,666์กฐ, 8,861์ต, $1,234, 315,500 โ never bare digit runs (1666์กฐ). Grouping uses comma separators in every locale. Views NEVER hand-assemble money strings (${x}์ต, toFixed(0) + '์กฐ'); they call the platform's canonical formatter:
| Platform | Canonical formatter |
|---|---|
| Web โ general (counts, percents, durations, bytes) | formatStatValue / formatCount / formatPercent |
| Web โ money | formatCompactMoney โ ์ต/์กฐ/ๅ
/ๅ, finance surfaces only |
| iOS | NumberFormatter (.decimal, groupingSeparator) via a shared helper |
| Android | java.text.NumberFormat via a shared helper |
Compact notation (23K, 91.5K, 2.3๋ง) is permitted for exactly one purpose: a value whose job is a magnitude at a glance, in a space too small for the exact figure โ dashboard stat tiles, chart axis labels, sparkline captions. It comes from the canonical formatter (Intl notation: 'compact', locale-aware), never hand-assembled, and it does not exempt the value from grouping: a compact value that still carries thousands renders them grouped (1,234K).
Compact notation is banned wherever the exact number IS the meaning โ money owed or paid, credit balances, quantities in a receipt or a confirmation dialog, anything a person may need to reconcile against another system. Those render grouped in full. A compact value must never be a number's only rendition when the reader is accountable for it; pair it with the exact figure in a title or accessibility label.
locale is a required argument to the general formatter, never defaulted. new Intl.NumberFormat(undefined) resolves to the runtime's locale, which differs between the build-time prerender and the browser โ that is a hydration mismatch on every number on the page. Thread the app's language down and default at the call site, never inside the formatter.
Live specimens
page-titleEvery screen has one clear focal point
section-headingEvery screen has one clear focal point
label-primaryEvery screen has one clear focal point
label-secondaryEvery screen has one clear focal point
text-eyebrowEvery screen has one clear focal point
price-display$1,234
bg-surfacelabel-secondary
section-surfacelabel-secondary-ground
bg-backgroundlabel-secondary-ground
315,500 ยท $1,234 ยท 1,666,000