Type
Four axes kept apart so any one can pair with any other — and the only category with no response to screen size at all.
Family, size, weight and leading are separate scales rather than a set of ready-made styles. A preset can only offer the combinations somebody thought of; four axes multiply. The cost is that a heading names three tokens instead of one — and that is the right cost, because it is three decisions, and bundling them does not make them fewer.
Leading is the one most often welded to size elsewhere, and keeping it loose is what lets the same 24px run tight as a heading and normal as a pull quote.
Family
Four families — and three of them are currently the same stack.
| Token | Value | Preview |
|---|---|---|
| --font-family-display | … | Design and develop |
| --font-family-heading | … | Design and develop |
| --font-family-body | … | Design and develop |
| --font-family-code | … | Design and develop |
Named by role, not classification — body, heading, display, code — so the names survive a change of typeface. --font-family-sans would have to be renamed the day a serif was chosen for headings; heading would not.
The specimens are honest, not broken. Real faces are the last outstanding Phase 1 decision, and the aliases exist so that decision costs one line each — the hook is in place before there is anything to hang on it, which is the point of naming by role.
Size
Twelve steps, and the same words at every one of them.
| Token | Value | Preview |
|---|---|---|
| --font-size-3xs | … | Design and develop |
| --font-size-2xs | … | Design and develop |
| --font-size-xs | … | Design and develop |
| --font-size-sm | … | Design and develop |
| --font-size-base | … | Design and develop |
| --font-size-lg | … | Design and develop |
| --font-size-xl | … | Design and develop |
| --font-size-2xl | … | Design and develop |
| --font-size-3xl | … | Design and develop |
| --font-size-4xl | … | Design and develop |
| --font-size-5xl | … | Design and develop |
| --font-size-6xl | … | Design and develop |
T-shirt names anchored on base (16px) rather than numbered, because the useful question is how far from body text, not how many pixels. Everything from base up matches Tailwind's scale, so the names are already familiar — and there is no md, because 18px is lg and 20px is xl.
They are rem, and that is load-bearing. A reader who raises their browser font size gets larger text throughout, which a px scale would silently ignore. It is also what makes the whole scale movable from one declaration — see below.
The extremes are unused: 4xl to 6xl wait for a hero, and 3xs at 8px is close to unreadable. 2xs is the practical floor, and it is what every label on this site uses.
Weight
Five of the nine weights CSS allows.
| Token | Value | Preview |
|---|---|---|
| --font-weight-light | … | Design and develop |
| --font-weight-regular | … | Design and develop |
| --font-weight-medium | … | Design and develop |
| --font-weight-semibold | … | Design and develop |
| --font-weight-bold | … | Design and develop |
Named rather than numbered so a face can be swapped without every rule changing: semibold stays semibold even if the family that provides it maps to a different numeric axis.
light carries a warning in its own comment — it pairs badly with small sizes, where thin strokes and low contrast compound into something that fails to read. In a monochrome system weight is one of only two emphasis levers, the other being colour, which is why the ramp is five steps rather than three.
Leading
Unitless multipliers — and the one preview that has to wrap.
| Token | Value | Preview |
|---|---|---|
| --line-height-compact | … | Leading is the space a line of text is given to breathe, and it only exists once there are two lines to sit between. A single line can show a size or a weight; it cannot show this. |
| --line-height-none | … | Leading is the space a line of text is given to breathe, and it only exists once there are two lines to sit between. A single line can show a size or a weight; it cannot show this. |
| --line-height-tight | … | Leading is the space a line of text is given to breathe, and it only exists once there are two lines to sit between. A single line can show a size or a weight; it cannot show this. |
| --line-height-snug | … | Leading is the space a line of text is given to breathe, and it only exists once there are two lines to sit between. A single line can show a size or a weight; it cannot show this. |
| --line-height-normal | … | Leading is the space a line of text is given to breathe, and it only exists once there are two lines to sit between. A single line can show a size or a weight; it cannot show this. |
| --line-height-relaxed | … | Leading is the space a line of text is given to breathe, and it only exists once there are two lines to sit between. A single line can show a size or a weight; it cannot show this. |
Unitless, and that matters more than it looks. A unitless line-height multiplies the element's own font size, so it stays correct when inherited; line-height: 24px on a container hands 24px to a 48px heading inside it and the lines collide.
compact is below 1 and belongs only to large display headings, where the lines are far enough apart in absolute terms to survive it. normal at 1.4 is the inherited default on body, so it carries UI text; relaxed at 1.5 is what every p uses, so prose gets slightly more air than the interface around it.
1.5 puts a 16px line box on exactly 24px — the 4px grid, and also --space-component-6, so a paragraph’s lines align to the same grid as everything around them. It was 1.7, which lands on 27.2px and was the one uneven step here: +0.30 where the two below it are +0.15 and +0.10.
That property cannot generalise, and the arithmetic is the reason to stop looking. A unitless multiplier lands on a 4px box only when the size has the right factors, and this scale contains 10, 14, 18 and 30. Measured across all twelve sizes: 1.5 lands on four, 1.6 on a different four, 1.15 and 1.3 and 1.45 on none — and only 2 lands on all twelve, being far too loose to use. 1.5 wins its tie with 1.6 because its four are base and 2xl, the sizes text actually sits at. So snapping stays a property of the derivation: computing leading from size and rounding would weld the two together and cost this axis its independence, which is the one thing it is here to keep.
Element defaults
Every row is four tokens off the four ramps above — a preset set in disguise.
| Element | Family | Size | Weight | Leading |
|---|---|---|---|---|
| h1 | heading | 3xl | bold | tight |
| h2 | heading | 2xl | semibold | tight |
| h3 | heading | xl | semibold | tight |
| h4 | heading | lg | semibold | tight |
| h5 | heading | base | semibold | tight |
| h6 | heading | sm | semibold | tight |
| p | body | base | regular | relaxed |
| code | code | 0.9em | inherit | inherit |
Semantic HTML is already styled — an <h2> looks like a heading because it is one, no class and no wrapper. code is the row that proves the rule extends past headings — and the row where the value is not from the size scale.
0.9em is a ratio, not a size. em multiplies the parent's font size, so inline code lands at 21.6px in a 24px heading and 14.4px in a paragraph — 90% of whatever it sits in. Every token on this page is rem, which resolves against the root and is therefore one fixed size everywhere: sm would render code at 14px inside a 24px heading, at 58% of the words beside it. The two agree only in body text, which is exactly where the near-match is tempting.
Why 90% at all: a monospace face at the same point size reads larger than the text around it — bigger x-height, wider letterforms — so the ratio is an optical correction, not a size decision. It stays a raw value while the families are placeholders, because the right number depends on which mono is chosen.
The space below a heading is set here too, and in em for the reason just given. 0.5em resolves against the heading's own size, so one declaration gives an h1 15px and an h6 7px — more air under a bigger heading, with no per-level values to drift. A --space-* step would hand a 14px h6 the same gap as a 30px h1.
The space above a heading is not set here, and that is the more interesting half. It comes from the layout — the seam between two bands — because separating one region from the next is a layout decision that should breathe with the container, which a fixed margin cannot do. The rule that survives: gap supplies the uniform rhythm, margin supplies only the asymmetry.
What this reaches is exactly the elements that exist. <small> and <label> are real tags and belong in this table — they are simply not typed yet. What has no element at all is narrower than it first looks: a display size is a hero's look rather than a seventh heading level, an overline is a style with no tag, and a heading-shaped thing that must not be an <h2> semantically has nowhere to go.
Those become a Text component in Phase 4 rather than more tokens, on the rule this system keeps arriving at: a token is a value, and a preset is a combination. The list is already written — 27 separate font-size declarations in the viewer's own stylesheet, each pairing a size with a weight and a colour, discovered by need rather than guessed.
Fluid at the root, not per step
Every size on this page is a fixed multiplier, so none of them is fluid on its own. One value decides what they multiply. --font-size-root is clamp(0.875rem, 0.8rem + 0.4vw, 1rem), and because the whole ramp is rem, moving it moves all twelve steps together — 14px at the floor, 16px from an 800px viewport up.
The ratios between the steps are therefore fixed by construction. Clamping each step instead means twelve independent min/max pairs, and a heading two-and-a-half times the body text on a desktop can be one-and-a-half times on a phone — a change in the hierarchy that nobody chose and no declaration records.
The middle term carries a rem deliberately. On the root element rem means the size the reader set in their browser, so their preference still multiplies through; a bare vw would discard it and pin the text to the window. Keying off the viewport where spacing keys off the container is a distinction rather than an inconsistency: type size is a question about the reader and their device; spacing is a question about the layout. The measures follow for free, being rem too.
What it cannot do is make headings shrink faster than body text — at a 14px root, 6xl is still 52px on a phone where it wants to be half that. Display sizes need their own clamps, and conveniently those are the three steps nothing uses yet, so they can arrive with the first hero that needs them.