Typography Scale skill for Claude Code

One ratio, readable measure, zero font shift. Claude derives every font size from one modular ratio, generates fluid clamp() values with a bundled script that flags zoom-unsafe steps, and sets leading, tracking and line length per role. Fonts get preloaded, subset and paired with metric-matched fallbacks so the page stops jumping when the web font arrives.

  • nametypography-scale
  • size169 lines · ≈1,800 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Sets up web typography that reads well and looks intentional — modular type scales, fluid sizes with clamp(), font pairing, line length and leading, weight and tracking rules, and fast web font loading with preload, subsetting, and fallback metric overrides to stop layout shift. Includes a clamp() scale generator. Use when picking fonts, defining heading sizes, fixing cramped or oversized text, or when fonts cause CLS or a flash of invisible text.

Try asking

  • Set up a fluid type scale for our marketing site with a serif display face
  • Our headings all look the same size. Fix the hierarchy
  • The font swap causes layout shift on load. Fix the font loading

Use it when

  • Choosing and pairing fonts for a product or site
  • Defining heading and body sizes for a design system
  • Text looks cramped, too wide, or lacks hierarchy
  • Web fonts cause CLS, FOIT, or slow first paint

Skip it when

How to check the result

Every font-size resolves to a scale token, body lines run 45 to 75 characters, CLS from the font swap is zero, and 200% zoom shows no clipped text.

The common mistake it prevents

Using pure vw units for fluid headings, which look fine at every width but refuse to grow when users zoom, failing WCAG resize-text requirements.

~/.claude/skills/typography-scale/SKILL.md

--- name: typography-scale description: Sets up web typography that reads well and looks intentional — modular type scales, fluid sizes with clamp(), font pairing, line length and… license: MIT ---

Typography is done when body text sits at 16–20px with 1.45–1.6 leading and 45–75 characters per line, headings come from one ratio, no more than two families and four weights load, and swapping in the web font moves nothing on the page.

Ground rules

  • One scale, one ratio. Every font size in the product is a step on it; no 15px, 17px, or 22px strays.
  • Body size first, then derive. Do not start from the hero headline.
  • Hierarchy comes from size + weight + color together. If headings need all-caps, underline, and a color to stand out, the size step is too small.
  • Two families maximum (often one is enough). A variable font counts as one file.
  • Units: rem for font sizes so user zoom and browser font settings work; unitless line-height.

Avoid the generic look

Default reflexBetter
Inter everywhere at 400/600, centeredChoose a face with character for display (a grotesk, a serif, a mono) and keep a quiet text face; left-align long text
Giant 72px hero over three tiny 14px gray linesContrast via a deliberate ratio (display ≈ 3–4× body), body at 18px on marketing pages
Gradient-filled headline textSolid text color; spend emphasis on weight or a single accent word
Letter-spacing untouched on huge headingsTighten display sizes: -0.01em at 32px, -0.02em at 48px+, -0.03em at 72px+
All headings bold 700Display at 500–600 often looks more refined; reserve 700 for small headings that need weight

Workflow

- [ ] 1. Decide context: product UI (dense) or editorial/marketing (expressive)
- [ ] 2. Pick families and load only needed weights
- [ ] 3. Generate the scale (static or fluid)
- [ ] 4. Set leading, measure, tracking per role
- [ ] 5. Load fonts without FOIT/CLS
- [ ] 6. Verify on real content at 320px and 1440px, and at 200% zoom

1–3. Choose ratio and generate

ContextBaseRatioNotes
Dense product UI, dashboards14–16px1.125–1.2Few steps used; 12px floor for captions
General app / docs16px1.2Safe default
Marketing, editorial18px1.25–1.333Larger display jumps
Posters, hero-led landing pages18–20px1.333–1.5 at desktop onlyUse fluid scaling so mobile stays sane

Generate a fluid scale (ratio and base both grow with viewport):

python3 scripts/type_scale.py --base-min 16 --base-max 18 --ratio-min 1.2 --ratio-max 1.25 \
  --vw-min 360 --vw-max 1280 --steps=-1:5

Output is a :root block of --text-* custom properties using clamp(min-rem, X-rem + Y-vw, max-rem). The rem term in the preferred value keeps zoom working; the script exits 1 if any step grows more than 2.5× between ends, which risks failing WCAG 1.4.4 (text resizable to 200%).

Static scale at 1.25 from 16px, rounded: 13, 16, 20, 25, 31, 39, 49, 61.

4. Leading, measure, tracking

RoleSizeLine-heightTrackingWeight
Display / hero40–72px1.0–1.1-0.02 to -0.03em500–700
H1–H228–40px1.15–1.25-0.01 to -0.02em600
H3–H418–24px1.30600
Body16–20px1.5–1.60400
UI labels, buttons13–15px1.2–1.40 to 0.01em500
Caption, meta12–13px1.40.01em400–500
All-caps eyebrow11–13px1.20.06–0.1em600
body { font-size: var(--text-base); line-height: 1.55; }
.prose { max-width: 68ch; }                    /* 45–75 characters per line */
h1, h2, h3 { text-wrap: balance; }             /* even heading line lengths */
p, li { text-wrap: pretty; }                   /* avoids single-word last lines */
.num { font-variant-numeric: tabular-nums; }   /* tables, prices, counters */

Spacing follows type: paragraph gap ≈ 1em of body size; space above a heading ≈ 2× the space below it, so it binds to the content it introduces.

2. Pairing rules

  • Contrast in structure, not mood: serif display + sans text, or grotesk + mono for developer products. Two similar sans-serifs look like a mistake.
  • Match x-heights roughly so mixed lines do not look uneven.
  • Check the face has the glyphs you need: tabular figures, the languages you ship, real italics if you use emphasis.
  • System stack when performance or native feel matters: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif.

5. Load fonts fast

<link rel="preload" href="/fonts/brand-var.woff2" as="font" type="font/woff2" crossorigin>
@font-face {
  font-family: "Brand";
  src: url("/fonts/brand-var.woff2") format("woff2");
  font-weight: 300 800;          /* variable range */
  font-display: swap;
}
/* metric-matched fallback to remove layout shift when the web font arrives */
@font-face {
  font-family: "Brand Fallback";
  src: local("Arial");
  size-adjust: 104%;
  ascent-override: 92%;
  descent-override: 24%;
  line-gap-override: 0%;
}
body { font-family: "Brand", "Brand Fallback", ui-sans-serif, system-ui, sans-serif; }
  • Preload only the one or two files used above the fold; crossorigin is required even for same-origin fonts or the preload is wasted.
  • Calculate override values with a tool (Fontaine, Capsize, or next/font, which generates them automatically) rather than guessing.
  • Subset to the scripts you serve: pyftsubset Brand.ttf --unicodes="U+0000-00FF,U+2000-206F" --flavor=woff2 --layout-features='*' --output-file=brand-latin.woff2 (needs pip install fonttools brotli).
  • Self-host instead of third-party CSS; it saves a connection and lets you preload.
  • Next.js: next/font/google or next/font/local self-hosts, subsets, and adds the fallback metrics; use it instead of manual <link> tags.

Gotchas

  • vw-only font sizes (font-size: 4vw) do not grow with browser zoom: an accessibility failure. Always combine with rem in clamp().
  • Line-height in px breaks when sizes change; keep it unitless.
  • Light weights (300 and below) on body text fail readability long before contrast math says so, especially on Windows rendering.
  • Loading 6+ font files for “flexibility” adds hundreds of KB; each weight/style is a file unless the font is variable.
  • font-display: optional prevents swap shift entirely but may never show the web font on slow first visits; choose it deliberately.
  • Centered text over 3 lines is hard to read; center only short headings and CTAs.

Verification

  • Grep for font sizes not from the scale: grep -rnE 'font-size:\s*[0-9.]+(px|rem)' src | grep -v 'var(--text'.
  • Measure line length: a body paragraph at desktop width shows 45–75 characters.
  • Lighthouse / DevTools Performance: CLS from font swap is 0 (no layout shift entries attributed to text nodes).
  • Zoom to 200% at 1280px wide: no clipped or overlapping text, no horizontal scroll at 320px CSS width (WCAG 1.4.10).
  • Network panel: only the expected font files load, all woff2, preloaded ones used.

Output contract

## Type system
Families: Display "X" (600), Text "Y" (400/500/600), Mono "Z" (400)
Scale: 16→18px base, 1.2→1.25 ratio, fluid 360–1280px (CSS attached)
| Token | Min | Max | Line-height | Tracking | Use |
Loading: preload X-var.woff2; fallback metrics set; subset latin

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/type_scale.py 75 lines · 2.8 KB
#!/usr/bin/env python3
"""Generate a fluid modular type scale as CSS clamp() custom properties.

Usage:
  python3 type_scale.py [--base-min 16] [--base-max 18] [--ratio-min 1.2]
                        [--ratio-max 1.25] [--vw-min 360] [--vw-max 1280]
                        [--steps -1:5] [--prefix text]

Each step n has size base * ratio**n at both ends of the viewport range, and the
preferred value is a rem + vw mix so browser zoom still scales the text.
Warns (exit 1) if any step's max/min exceeds 2.5, which risks failing WCAG 1.4.4
(resize text to 200%).
"""
import argparse
import sys

NAMES = {-2: "xs", -1: "sm", 0: "base", 1: "lg", 2: "xl", 3: "2xl", 4: "3xl", 5: "4xl", 6: "5xl", 7: "6xl"}


def fmt(x):
    s = f"{x:.4f}".rstrip("0").rstrip(".")
    return s if s not in ("", "-0") else "0"


def main():
    p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    p.add_argument("--base-min", type=float, default=16)
    p.add_argument("--base-max", type=float, default=18)
    p.add_argument("--ratio-min", type=float, default=1.2)
    p.add_argument("--ratio-max", type=float, default=1.25)
    p.add_argument("--vw-min", type=float, default=360)
    p.add_argument("--vw-max", type=float, default=1280)
    p.add_argument("--steps", default="-1:5", help="range like -1:5")
    p.add_argument("--prefix", default="text")
    a = p.parse_args()

    lo, hi = (int(x) for x in a.steps.split(":"))
    if a.vw_max <= a.vw_min:
        sys.exit("--vw-max must exceed --vw-min")

    warn = 0
    rows, css = [], []
    for n in range(lo, hi + 1):
        smin = a.base_min * a.ratio_min ** n
        smax = a.base_max * a.ratio_max ** n
        slope = (smax - smin) / (a.vw_max - a.vw_min)
        intercept = smin - slope * a.vw_min
        name = NAMES.get(n, f"step-{n}")
        if abs(slope) < 1e-9:
            value = f"{fmt(smin / 16)}rem"
        else:
            lo_v, hi_v = sorted((smin, smax))
            value = (f"clamp({fmt(lo_v / 16)}rem, {fmt(intercept / 16)}rem + {fmt(slope * 100)}vw, "
                     f"{fmt(hi_v / 16)}rem)")
        ratio = max(smin, smax) / min(smin, smax)
        flag = ""
        if ratio > 2.5:
            flag = "  ! max/min > 2.5: zoom risk"
            warn += 1
        rows.append(f"{name:<6}{smin:>8.2f}px{smax:>9.2f}px{flag}")
        css.append(f"  --{a.prefix}-{name}: {value};")

    print(f"Scale: base {a.base_min:g}->{a.base_max:g}px, ratio {a.ratio_min:g}->{a.ratio_max:g}, "
          f"viewport {a.vw_min:g}->{a.vw_max:g}px\n")
    print(f"{'step':<6}{'min':>10}{'max':>11}")
    print("\n".join(rows))
    print("\n:root {\n" + "\n".join(css) + "\n}")
    if warn:
        print(f"\n{warn} step(s) scale more than 2.5x; reduce the ratio spread or cap the max.", file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc