Frontend Impl Typography System
This skill defines a deterministic rule set for shipping a typographic system : modular scale + fluid clamp() type, variable-font axes (preferring high-level shorthand over font-variation-settings), OpenType features via font-variant-* (with font-feature-settings reserved for stylistic sets), CLS-safe web-font loading (font-display: swap + metric-matched fallback), and text-wrap: balance|pretty (Baseline 2024). The skill builds on [[frontend-impl-design-tokens]] (token emission as CSS custom properties) and [[frontend-perf-core-web-vitals-inp]] (LCP / CLS budgets).
Sources : MDN: font-variation-settings (verified 2026-05-19), MDN: font-variant (verified 2026-05-19), MDN: font-feature-settings (verified 2026-05-19), MDN: @font-face/font-display (verified 2026-05-19), MDN: text-wrap (verified 2026-05-19), MDN: clamp() (verified 2026-05-19), MDN: @font-face/size-adjust (verified 2026-05-19), MDN: @font-face/ascent-override (verified 2026-05-19), web.dev: Preload critical assets (verified 2026-05-19).
Quick Reference
Modular scale ratios
| Ratio | Name | Best for |
|---|---|---|
| 1.125 | Minor second | Very dense UI (tables, dashboards). Steps barely distinguishable. |
| 1.2 | Minor third | Compact app UI. |
| 1.25 | Major third | General app UI. The most common default. |
| 1.333 | Perfect fourth | Marketing-leaning products with mixed editorial sections. |
| 1.414 | Augmented fourth (sqrt 2) | Paper-document-feel layouts. |
| 1.5 | Perfect fifth | Editorial / long-form content. |
| 1.618 | Golden ratio | Display / marketing landing pages with strong hierarchy. |
Rule of thumb : ratio is LARGER for editorial, SMALLER for dense UI.
Fluid clamp() recipe
font-size: clamp(MIN_REM, BASE_REM + SLOPE_VW, MAX_REM);
| Rule | Detail |
|---|---|
MIN, MAX in rem |
Honour the user's root font size. |
Mix rem + vw in the preferred expression |
Pure vw collapses to unreadable on narrow viewports; pure rem is not fluid. |
MAX >= 2 x MIN |
WCAG 1.4.4 Resize Text. Allows browser zoom to 200% without hitting the cap. |
| Verify at design min / max viewports | If the preferred resolves outside [MIN, MAX], the value silently behaves like a constant. |
A full step-indexed scale (ratio 1.2) :
:root {
--step--2: clamp(0.69rem, 0.66rem + 0.18vw, 0.80rem);
--step--1: clamp(0.83rem, 0.78rem + 0.28vw, 1.00rem);
--step-0: clamp(1.00rem, 0.93rem + 0.42vw, 1.25rem);
--step-1: clamp(1.20rem, 1.10rem + 0.62vw, 1.56rem);
--step-2: clamp(1.44rem, 1.30rem + 0.91vw, 1.95rem);
--step-3: clamp(1.73rem, 1.53rem + 1.32vw, 2.44rem);
--step-4: clamp(2.07rem, 1.80rem + 1.91vw, 3.05rem);
--step-5: clamp(2.49rem, 2.12rem + 2.76vw, 3.81rem);
}
Variable-font registered axes
Per MDN: font-variation-settings (verified 2026-05-19). ALWAYS use the shorthand for registered axes; reserve font-variation-settings for CUSTOM axes only.
| Axis tag | Use this shorthand | Notes |
|---|---|---|
"wght" |
font-weight: <number> |
100..900 plus intermediate values supported by variable fonts. |
"wdth" |
font-stretch: <percentage> |
Condensed (50%) to expanded (200%). |
"slnt" |
font-style: oblique <angle> |
Slant angle in degrees. |
"ital" |
font-style: italic |
Italic toggle (0/1). |
"opsz" |
font-optical-sizing: auto |
Optical sizing matched to font-size. |
CRITICAL RULE (per MDN) : "Font characteristics set using font-variation-settings will always override those set using the corresponding basic font properties, e.g., font-weight, no matter where they appear in the cascade." NEVER mix the two for the same axis ; pick one mechanism per axis project-wide. Prefer the shorthand.
font-variant-* over font-feature-settings
Per MDN: font-feature-settings (verified 2026-05-19) : "Whenever possible, Web authors should instead use the font-variant shorthand property or an associated longhand property... font-feature-settings shouldn't be used to enable small caps."
| OpenType feature | Use this font-variant-* |
|---|---|
Small caps (smcp) |
font-variant-caps: small-caps |
All small caps (c2sc, smcp) |
font-variant-caps: all-small-caps |
Tabular numerals (tnum) |
font-variant-numeric: tabular-nums |
Lining numerals (lnum) |
font-variant-numeric: lining-nums |
Old-style numerals (onum) |
font-variant-numeric: oldstyle-nums |
Slashed zero (zero) |
font-variant-numeric: slashed-zero |
Diagonal fractions (frac) |
font-variant-numeric: diagonal-fractions |
Common ligatures (liga) |
font-variant-ligatures: common-ligatures (default on) |
Discretionary ligatures (dlig) |
font-variant-ligatures: discretionary-ligatures |
Subscript / Superscript (subs / sups) |
font-variant-position: sub / font-variant-position: super |
Reserve font-feature-settings for :
- Stylistic sets :
font-feature-settings: "ss03" 1; - Character-variant selectors :
font-feature-settings: "cv11" 1; - Font-specific features that have NO
font-variant-*keyword.
NEVER mix font-feature-settings and font-variant-* for the SAME feature ; behaviour is implementation-defined.
font-display value matrix
Per MDN: @font-face/font-display (verified 2026-05-19), Baseline since January 2020.
| Value | Block period | Swap period | Effect | Use for |
|---|---|---|---|---|
auto |
UA-defined | UA-defined | usually equivalent to block |
NEVER |
block |
~3 s | infinite | FOIT then FOUT-on-swap | NEVER (kills LCP) |
swap |
~0 ms | infinite | FOUT immediate; webfont swaps in | DEFAULT for hero / heading webfonts (paired with metric-matched fallback) |
fallback |
~100 ms | ~3 s | tiny invisible window, then fallback ; webfont only swaps if it arrives within 3 s | Below-the-fold body when slight FOIT-glimpse is acceptable |
optional |
~100 ms | 0 | tiny invisible window, then fallback ; webfont only used if loaded almost instantly (cached) | Above-the-fold body where stable LCP is the priority |
Web-font preload (LCP-critical)
<link rel="preload" href="/fonts/Inter-roman.var.woff2" as="font" type="font/woff2" crossorigin>
Rules :
crossoriginis MANDATORY (even for same-origin fonts) ; without it the browser fetches the font twice.as="font"is MANDATORY ; an incorrectasvalue triggers wrong priority and wastes the preload.- Preload ONLY LCP-critical fonts. Preloading too many resources de-prioritises everything.
- Use
woff2only. WOFF1 / TTF / OTF have no place in 2026 evergreen.
text-wrap (Baseline 2024)
Per MDN: text-wrap (verified 2026-05-19).
| Value | Effect | Use for |
|---|---|---|
wrap (default) |
Greedy line-breaking. | n/a |
nowrap |
No wrap. | Tags, badges, single-line UI. |
balance |
Distributes characters equally across lines. Capped at 6 lines (Chromium) / 10 lines (Firefox) ; silently falls back beyond. | Headings (h1-h3), captions, blockquote pull-quotes. |
pretty |
Slower algorithm prioritising orphan minimisation and overall layout quality. | Body copy where typography matters. |
stable |
Keeps existing line breaks stable while content changes (typing in a contenteditable). | [contenteditable]. |
CLS-safe fallback @font-face
@font-face {
font-family: "Inter";
src: url("/fonts/Inter-roman.var.woff2") format("woff2-variations");
font-weight: 100 900;
font-display: swap;
}
@font-face {
font-family: "Inter-fallback";
src: local("Arial");
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22.5%;
line-gap-override: 0%;
}
body { font-family: "Inter", "Inter-fallback", system-ui, sans-serif; }
| Descriptor | Baseline | Notes |
|---|---|---|
size-adjust |
Baseline Widely Available (September 2023) | Scales all glyph metrics by a percentage. LOAD-BEARING for CLS prevention. |
ascent-override |
Limited Availability in 2026 | Sets ascent above baseline. Progressive enhancement. |
descent-override |
Limited Availability in 2026 | Sets descent below baseline. |
line-gap-override |
Limited Availability in 2026 | Sets line-gap between lines. |
size-adjust alone removes most font-swap CLS. The override descriptors close the remaining gap when supported.
Decision Trees
Decision : font-variant-* or font-feature-settings?
Does the OpenType feature have a font-variant-* keyword?
Yes (small caps, tabular nums, common ligatures, fractions,
slashed zero, subscript / superscript, oldstyle nums, etc.)
-> ALWAYS font-variant-* (or the font-variant shorthand).
NEVER raw font-feature-settings even though the OpenType tag
would technically work.
No (stylistic set ssNN, character variant cvNN, or any font-specific
feature without a registered keyword)
-> font-feature-settings: "ssNN" 1, "cvNN" 1;
Are both forms in the same stylesheet for the same feature?
-> Pick one. Delete the other. Mixed forms have
implementation-defined behaviour.
Decision : font-display: swap or optional (or anything else)?
Is this font on the page's LCP candidate text?
Yes (h1 / hero text / above-the-fold large copy)
-> font-display: swap.
Preload with <link rel=preload as=font crossorigin>.
Pair with metric-matched fallback @font-face to absorb FOUT.
No, but the font is above-the-fold (nav, byline, body intro)
-> font-display: optional. Browser uses webfont only if it
arrives almost instantly (cached); otherwise the fallback
stays for the whole session. LCP-friendly.
No, the font is below-the-fold
-> font-display: fallback. Small invisible window then fallback;
webfont swaps in if it arrives within 3s.
NEVER block (3-second FOIT) or auto (browser-dependent).
Decision : text-wrap: balance or pretty?
What kind of text is it?
Heading (h1, h2, h3, h4) or short caption that is at most 6 lines.
-> text-wrap: balance.
Distributes characters equally; produces visually balanced
multi-line titles instead of an orphan word.
Body paragraph (p, li, dd) that may be many lines long.
-> text-wrap: pretty.
Optimises for orphan minimisation and overall flow.
Skip if perf-critical on text-heavy pages.
Editable area where the user is typing.
-> text-wrap: stable.
Keeps existing line breaks stable as the user types.
Nav links, tags, single-line labels.
-> text-wrap: nowrap (or leave default if wrap is desired).
Decision : variable font or static webfonts?
How many weights / styles do you ship?
3 or more weights, or any custom axis, or you need oblique angles
intermediate between regular and italic.
-> Variable font (woff2-variations). One file covers all weights;
use font-weight, font-stretch, font-style, font-optical-sizing
for the registered axes; font-variation-settings ONLY for
custom axes.
1 or 2 weights, no custom axes.
-> Static webfonts MAY be acceptable. But a typical variable
font is 80-150 KB; six static fonts at 100 KB each is 600 KB.
Variable usually wins.
How do you set the weight?
Variable font -> font-weight: <number> shorthand.
NEVER font-variation-settings: "wght" N (overrides shorthand
silently regardless of cascade order).
Patterns
Pattern 1 : full type system with tokens
:root {
--step--2: clamp(0.69rem, 0.66rem + 0.18vw, 0.80rem);
--step--1: clamp(0.83rem, 0.78rem + 0.28vw, 1.00rem);
--step-0: clamp(1.00rem, 0.93rem + 0.42vw, 1.25rem);
--step-1: clamp(1.20rem, 1.10rem + 0.62vw, 1.56rem);
--step-2: clamp(1.44rem, 1.30rem + 0.91vw, 1.95rem);
--step-3: clamp(1.73rem, 1.53rem + 1.32vw, 2.44rem);
--step-4: clamp(2.07rem, 1.80rem + 1.91vw, 3.05rem);
--line-tight: 1.2;
--line-normal: 1.5;
--line-loose: 1.75;
}
body { font-size: var(--step-0); line-height: var(--line-normal); font-optical-sizing: auto; }
h1 { font-size: var(--step-4); line-height: var(--line-tight); text-wrap: balance; }
h2 { font-size: var(--step-3); line-height: var(--line-tight); text-wrap: balance; }
h3 { font-size: var(--step-2); line-height: var(--line-tight); text-wrap: balance; }
p { text-wrap: pretty; max-inline-size: 65ch; }
table { font-variant-numeric: tabular-nums; }
Pattern 2 : variable font with metric-matched fallback (no CLS)
@font-face {
font-family: "Inter";
src: url("/fonts/Inter-roman.var.woff2") format("woff2-variations");
font-weight: 100 900;
font-display: swap;
}
@font-face {
font-family: "Inter-fallback";
src: local("Arial");
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22.5%;
line-gap-override: 0%;
}
:root { font-family: "Inter", "Inter-fallback", system-ui, sans-serif; }
Preload in <head> :
<link rel="preload" href="/fonts/Inter-roman.var.woff2" as="font" type="font/woff2" crossorigin>
Pattern 3 : variable-font custom axis
.expressive {
font-weight: 600;
font-optical-sizing: auto;
font-variation-settings: "GRAD" 80, "YOPQ" 90;
}
Custom axes (uppercase 4-character tags) MAY use font-variation-settings ; registered axes (lowercase) MUST use their shorthand.
Anti-Patterns Index
See anti-patterns.md. Ten cataloged : line-height: 24px not unitless; font-display: block or auto; mixing font-weight with font-variation-settings: "wght" N; font-feature-settings: "smcp" 1 instead of font-variant-caps: small-caps; preload missing crossorigin; clamp() MAX < 2 x MIN; 6 static webfonts vs one variable; pure-vw font-size; text-wrap: balance on body paragraphs; no matched-fallback @font-face.
Reference Links
- Methods and signatures : full property surface, OpenType tag map, axis table, font-display matrix, preload recipe, CLS metric-override descriptors.
- Examples : renderable HTML demo with fluid scale + variable-font axis + font-variant-numeric live toggles ; nine additional patterns.
- Anti-patterns : ten cataloged anti-patterns with symptom, root cause, and fix.
Cross-references
[[frontend-impl-design-tokens]]: token emission as CSS custom properties.[[frontend-impl-responsive-layout-fluid]]:clamp()+ viewport units that drive the fluid scale.[[frontend-perf-core-web-vitals-inp]]: LCP and CLS budgets that constrain font loading choices.[[frontend-syntax-css-color-modern]]:@propertyand modern CSS surface.[[frontend-a11y-motion-contrast-wcag22]]: WCAG 1.4.4 Resize Text and minimum contrast.