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.

RoleSize / WeightWeb UtilityiOS (AppTheme.Typography)AndroidUse
page-title22px / bold.page-titlepageTitle (28pt bold)headlineMediumScreen-level headings
section-heading11px / bold, tracked.section-headingsectionHeading (13pt semibold)labelSmallSection labels
label-primary14px / semibold.label-primarylabelPrimary (15pt semibold)titleSmallCard titles, primary labels
label-secondary12px / regular.label-secondarylabelSecondary (13pt regular)bodySmallCaptions, secondary info
body15px / regularโ€”bodyText (15pt regular)bodyMediumBody content
caption11โ€“12px / regularโ€”caption (12pt regular)labelSmallTimestamps, metadata
price30โ€“32px / black.price-displayprice (32pt black)displaySmallPricing, large numeric display
eyebrow11px / bold.text-eyebroweyebrow (11pt bold)labelSmallTiny 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 hardcoded sp values.
  • Web: use utility classes (page-title, section-heading, label-primary, label-secondary) โ€” never inline text-[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).

TokenLightDarkOn its surfaceWebiOSAndroidUse
text-primary#0A1931#F8FAFC~16:1text-primetextPrimaryonSurfaceHeadings, body, primary content
text-secondary#71717A#A1A1AA~4.6:1text-muted-foregroundtextSecondaryonSurfaceVariantSecondary, captions, metadata, labels โ€” the floor
text-on-brand#FFFFFF#FFFFFF~17:1 on brand navytext-on-brandtextOnBrandonPrimaryContainerPrimary text on brand / dark-gradient surfaces
text-on-brand-muted#CBD5E1#CBD5E1~9:1 on brand navytext-on-brand-mutedtextOnBrandMutedโ€”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):

UtilitySpecReplaces
label-primarysemibold 14px text-primecard titles
label-secondary12px text-secondarycaptions, metadata
section-heading14px bold tracked text-secondarysection labels
text-eyebrow11px bold tracking-wide, zinc-600 / zinc-400 โ€” never the muted token: 4.40:1 on zinc-100 at 11pxtiny 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:

PlatformCanonical formatter
Web โ€” general (counts, percents, durations, bytes)formatStatValue / formatCount / formatPercent
Web โ€” moneyformatCompactMoney โ€” ์–ต/์กฐ/ๅ…†/ๅ„„, finance surfaces only
iOSNumberFormatter (.decimal, groupingSeparator) via a shared helper
Androidjava.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

Type scale
  • page-title

    Every screen has one clear focal point

  • section-heading

    Every screen has one clear focal point

  • label-primary

    Every screen has one clear focal point

  • label-secondary

    Every screen has one clear focal point

  • text-eyebrow

    Every screen has one clear focal point

  • price-display

    $1,234

Secondary text on each surface
bg-surface

label-secondary

section-surface

label-secondary-ground

bg-background

label-secondary-ground

Grouped numbers

315,500 ยท $1,234 ยท 1,666,000