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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- typography-systems Installs to ~/.agents/skills/typography-systems/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install typography-systems The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project typography-systems Installs to .agents/skills/typography-systems/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/typography-systems.zipunzip -o typography-systems.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) typography-systems PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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
- Font loading performance and layout shift only: use web-font-loading
- Spacing, grids and vertical rhythm: use layout-grids
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.
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 configfontSize, token files, global CSS,next/fontor@font-facedeclarations. - Inventory sizes actually rendered (DevTools or Playwright
page.evaluateovergetComputedStyle(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
| Role | Requirement |
|---|---|
| Text | Large x-height, open apertures, several weights, good at 14–18 px |
| Display | Personality at 32 px and up; can be fragile small |
| Mono | Code, IDs, tabular data; distinct 0/O and 1/l/I |
| UI | Often 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
| Context | Base | Ratio |
|---|---|---|
| Dense app UI, admin, data tools | 14–16 px | 1.125–1.2 |
| Product app, settings, forms | 16 px | 1.2 |
| Marketing pages | 16–18 px | 1.25 small screens to 1.333 large |
| Editorial / long-form | 18–21 px | 1.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: balanceevens out short headings (browsers limit it to a few lines);prettyavoids single-word last lines in paragraphs. Both degrade gracefully.hyphens: autoneeds a correctlangattribute on<html>.- Prefer
font-variant-*overfont-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
wght400–700 often beats four static files. font-display: swapplus a metric-matched fallback (size-adjust,ascent-override) keeps CLS near zero; frameworks likenext/fontgenerate 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 make1rem = 10pxbreaks 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-spacingin px does not scale with size; useem.- 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.yaml
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.py
#!/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.