Typography Systems skill for Codex

A short named type scale that reads well at any width. Codex replaces scattered font sizes with a small set of role-named text styles. It picks families by role, chooses a ratio per context, generates zoom-safe fluid clamp() steps with a bundled Python script, bundles size, leading, weight and tracking into composite styles, applies tabular numerals and text-wrap, and verifies 200% zoom and 320 px reflow.

  • Design & UX
  • $typography-systems
  • 171 lines
  • 3 files
  • ~1,850 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- typography-systems

Installs to ~/.agents/skills/typography-systems/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $typography-systems, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Designs typography systems for web and app UI - modular and fluid type scales generated with clamp(), font pairing by role, line height and measure rules, composite type tokens, OpenType features such as tabular numerals, text-wrap balance and pretty, and zoom and reflow checks, with a bundled scale generator script. Use when setting up or fixing a type scale, choosing or pairing fonts, making headings responsive, when text feels cramped, too wide or inconsistent, or when converting ad hoc font sizes into tokens.

Use it when

  • Creating or consolidating a type scale
  • Headings are too big on mobile or too small on desktop
  • Choosing and pairing fonts for a product or site
  • Body text feels cramped, too wide or hard to read

Use something else when

How to check the result

Rendered font sizes match the defined steps, body lines stay within 45 to 75 characters, and text survives 200% zoom and 320 px reflow without clipping.

The mistake it prevents

Using pure vw units for fluid type, which ignores browser zoom and user font settings and fails WCAG resize text.

~/.agents/skills/typography-systems/SKILL.md

--- name: typography-systems description: Designs typography systems for web and app UI - modular and fluid type scales generated with clamp(), font pairing by role, line height and measure… license: MIT ---

A type system is right when every text style on screen comes from a short named set (usually 6–9 sizes), body text is comfortable at every width (16 px or more, 45–75 characters per line, line height near 1.5), hierarchy is clear from size and weight alone, and the layout survives 200% zoom and 320 px reflow.

Before changing anything

  • Read AGENTS.md; find current type sources: @theme --text-*, Tailwind config fontSize, token files, global CSS, next/font or @font-face declarations.
  • Inventory sizes actually rendered (DevTools or Playwright page.evaluate over getComputedStyle(el).fontSize). Fifteen distinct sizes usually collapse to seven.
  • Identify contexts: dense app UI, marketing pages, long-form reading, data tables. They may share families but often need different scale ratios.

Workflow

- [ ] 1. Choose families by role
- [ ] 2. Choose base size and ratio per context
- [ ] 3. Generate the scale (fixed or fluid)
- [ ] 4. Define composite text styles (size + leading + weight + tracking)
- [ ] 5. Set measure, wrapping and OpenType features
- [ ] 6. Load fonts without layout shift
- [ ] 7. Replace ad hoc sizes; verify zoom, reflow and readability

1. Families by role

RoleRequirement
TextLarge x-height, open apertures, several weights, good at 14–18 px
DisplayPersonality at 32 px and up; can be fragile small
MonoCode, IDs, tabular data; distinct 0/O and 1/l/I
UIOften the text face; needs tabular figures and a medium weight

Pairing rules: contrast roles (serif display + sans text, or grotesk + mono), match x-heights roughly so mixed lines look even (font-size-adjust: ex-height from-font helps), and cap at two families plus mono. One variable family with a wide weight and optical-size range is a complete system on its own. For aesthetic-led pairings see frontend-design.

2. Base size and ratio

ContextBaseRatio
Dense app UI, admin, data tools14–16 px1.125–1.2
Product app, settings, forms16 px1.2
Marketing pages16–18 px1.25 small screens to 1.333 large
Editorial / long-form18–21 px1.25–1.414 for display only

Small ratios keep app screens calm; large ratios belong on pages with few headings.

3. Generate the scale

App UI: fixed rem steps are fine and easier to align with components. Marketing and editorial: fluid steps that grow with the viewport, capped at both ends. Generate them:

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

It prints --step-N: clamp(min, rem + vw, max) custom properties and a table of pixel sizes at both bounds. The preferred value always mixes rem with vw, so user font settings and zoom still scale text; pure vw type does not zoom and fails WCAG 1.4.4. It warns when a step grows more than 2.5x between bounds (zoom risk) or drops below 12 px. Add --strict in CI to fail on warnings.

4. Composite text styles

Name styles by role, not by size, and bundle every property:

:root {
  --font-text: "Source Sans 3", ui-sans-serif, system-ui, sans-serif;
  --font-display: "Fraunces", ui-serif, Georgia, serif;
  --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
}
.text-body    { font: 400 var(--step-0)/1.55 var(--font-text); }
.text-small   { font: 400 var(--step--1)/1.45 var(--font-text); }
.text-label   { font: 600 var(--step--1)/1.2 var(--font-text); letter-spacing: .01em; }
.text-eyebrow { font: 600 var(--step--1)/1.2 var(--font-text); letter-spacing: .08em; text-transform: uppercase; }
.text-h3      { font: 600 var(--step-2)/1.25 var(--font-text); }
.text-h2      { font: 600 var(--step-3)/1.15 var(--font-display); letter-spacing: -.01em; }
.text-h1      { font: 650 var(--step-5)/1.02 var(--font-display); letter-spacing: -.025em; }

Line height falls as size rises: body 1.45–1.65, subheads 1.2–1.3, display 0.95–1.1. A formula that does this automatically: line-height: calc(1em + 0.5rem).

Tailwind v4 equivalent in @theme:

@theme {
  --text-body: var(--step-0);
  --text-body--line-height: 1.55;
  --text-h1: var(--step-5);
  --text-h1--line-height: 1.02;
  --text-h1--letter-spacing: -0.025em;
}

5. Measure, wrapping and features

.prose { max-width: 65ch; hyphens: auto; text-wrap: pretty; }
h1, h2, h3 { text-wrap: balance; }
td, .stat, .price { font-variant-numeric: tabular-nums; }
.caps { font-variant-caps: all-small-caps; letter-spacing: .04em; }
  • Measure 45–75 characters; 65ch is a good default. Wide screens get wider margins or a second column, not wider lines.
  • text-wrap: balance evens out short headings (browsers limit it to a few lines); pretty avoids single-word last lines in paragraphs. Both degrade gracefully.
  • hyphens: auto needs a correct lang attribute on <html>.
  • Prefer font-variant-* over font-feature-settings; the latter resets every feature it does not list.
  • Right-align numeric table columns and use tabular figures so digits line up.

6. Loading

  • Load only the weights and axes used. A variable font with wght 400–700 often beats four static files.
  • font-display: swap plus a metric-matched fallback (size-adjust, ascent-override) keeps CLS near zero; frameworks like next/font generate this.
  • Preload only the one or two files used above the fold. Details: web-font-loading.
  • Installing a font package (@fontsource-variable/...) or fetching Google Fonts needs network; in an offline sandbox, wire the declaration and note it for the user.

Gotchas

  • Setting html { font-size: 62.5% } to make 1rem = 10px breaks user font preferences for any component that forgets to reset; keep the root at 100%.
  • Fluid type with a huge ratio at desktop makes h1 wrap to five lines on tablets. Check the middle widths (768–1024), not just the extremes.
  • All-caps text without extra tracking looks cramped; small caps need a font that has them or the browser synthesizes ugly ones.
  • Light weights (300 and below) on low-contrast gray fail readability even when the ratio passes. Body text weight 400 or more.
  • letter-spacing in px does not scale with size; use em.
  • Font fallback stacks with very different x-heights cause visible reflow on swap.

Verification

  • Inventory again: distinct rendered font sizes equal the defined steps.
  • Screenshot at 360, 768 and 1440 px; headings do not exceed three lines, body lines stay within 45–75 characters (count a typical line).
  • Browser zoom to 200%: no clipped or overlapping text (WCAG 1.4.4); at 320 px width no horizontal scroll (1.4.10).
  • Apply the WCAG 1.4.12 text-spacing override (line height 1.5, paragraph spacing 2em, letter spacing 0.12em, word spacing 0.16em) and confirm nothing clips.
  • Lighthouse or the Performance panel shows no CLS from font swap.

Output contract

**Families:** text <family>, display <family>, mono <family> (weights loaded: ...)
**Scale:** base 16->18 px, ratio 1.2->1.25, steps -2..5, fluid 360-1280 px (generated by fluid_type.py)
**Styles:** body, small, label, eyebrow, h3, h2, h1 (file: ...)
**Replaced:** <n> ad hoc sizes in <n> files; distinct sizes <before> -> <after>
**Checks:** 200% zoom ok, 320 px reflow ok, text-spacing ok, CLS from fonts <0.01

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Typography Systems"
  short_description: "A short named type scale that reads well at any width"
  default_prompt: "$typography-systems Set up a fluid type scale for our marketing site with clamp()"
scripts/fluid_type.py97 lines, 4.2 KB
#!/usr/bin/env python3
"""Generate a fluid modular type scale as CSS clamp() custom properties.

Standard library only (Python 3.10+).

  python3 fluid_type.py
  python3 fluid_type.py --min-vw 360 --max-vw 1440 --min-base 16 --max-base 19 \
      --min-ratio 1.2 --max-ratio 1.333 --steps=-2:6 --prefix text

Each step interpolates linearly between its size at --min-vw and at --max-vw. The
preferred value mixes rem and vw so browser zoom and user font-size settings still
apply. Warnings are printed (exit code 1 with --strict) when a step grows more than
2.5x between bounds, which can break WCAG 1.4.4 resize-to-200% behaviour, or when the
base size drops below 16px.
"""
from __future__ import annotations

import argparse
import sys


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


def clamp_expr(min_px: float, max_px: float, min_vw: float, max_vw: float, root: float) -> str:
    slope = (max_px - min_px) / (max_vw - min_vw)
    intercept_px = min_px - slope * min_vw
    lo, hi = sorted((min_px, max_px))
    sign = "-" if slope < 0 else "+"
    preferred = f"{fmt(intercept_px / root)}rem {sign} {fmt(abs(slope) * 100)}vw"
    if abs(slope) < 1e-9:
        return f"{fmt(min_px / root)}rem"
    return f"clamp({fmt(lo / root)}rem, {preferred}, {fmt(hi / root)}rem)"


def parse_steps(text: str) -> range:
    try:
        a, b = text.split(":")
        start, end = int(a), int(b)
    except ValueError as exc:
        raise argparse.ArgumentTypeError("steps must look like -2:5") from exc
    if start > end:
        raise argparse.ArgumentTypeError("steps start must be <= end")
    return range(start, end + 1)


def main() -> int:
    p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    p.add_argument("--min-vw", type=float, default=360, help="viewport width (px) where the min sizes apply")
    p.add_argument("--max-vw", type=float, default=1280, help="viewport width (px) where the max sizes apply")
    p.add_argument("--min-base", type=float, default=16, help="body size (px) at min viewport")
    p.add_argument("--max-base", type=float, default=18, help="body size (px) at max viewport")
    p.add_argument("--min-ratio", type=float, default=1.2, help="scale ratio at min viewport (1.2 minor third)")
    p.add_argument("--max-ratio", type=float, default=1.25, help="scale ratio at max viewport (1.25 major third)")
    p.add_argument("--steps", type=parse_steps, default=parse_steps("-2:5"), help="step range, e.g. -2:5")
    p.add_argument("--root", type=float, default=16, help="root font size in px used for rem")
    p.add_argument("--prefix", default="step", help="custom property prefix")
    p.add_argument("--strict", action="store_true", help="exit 1 if any warning is printed")
    a = p.parse_args()

    if a.max_vw <= a.min_vw:
        print("error: --max-vw must be greater than --min-vw", file=sys.stderr)
        return 2

    warnings: list[str] = []
    if a.min_base < 16:
        warnings.append(f"base size {fmt(a.min_base)}px at small screens is below 16px")

    rows = []
    print(f"/* Fluid type scale: {fmt(a.min_vw)}px -> {fmt(a.max_vw)}px, "
          f"base {fmt(a.min_base)}->{fmt(a.max_base)}px, ratio {a.min_ratio}->{a.max_ratio} */")
    print(":root {")
    for step in a.steps:
        lo = a.min_base * a.min_ratio ** step
        hi = a.max_base * a.max_ratio ** step
        name = f"--{a.prefix}-{step}" if step >= 0 else f"--{a.prefix}--{abs(step)}"
        print(f"  {name}: {clamp_expr(lo, hi, a.min_vw, a.max_vw, a.root)};")
        rows.append((name, lo, hi))
        if max(lo, hi) / min(lo, hi) > 2.5:
            warnings.append(f"{name} grows {max(lo, hi) / min(lo, hi):.2f}x; keep max/min <= 2.5 for zoom safety")
        if step < 0 and min(lo, hi) < 12:
            warnings.append(f"{name} drops to {min(lo, hi):.1f}px; avoid for anything users must read")
    print("}")

    print(f"\n{'token':<14}{'@' + fmt(a.min_vw) + 'px':>10}{'@' + fmt(a.max_vw) + 'px':>10}")
    for name, lo, hi in rows:
        print(f"{name:<14}{lo:>9.1f} {hi:>9.1f}")

    for w in warnings:
        print(f"warning: {w}", file=sys.stderr)
    return 1 if (a.strict and warnings) else 0


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

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc