Lesson 08 / 19
Typography Tokens
Defining text levels as composite tokens, fitting the line box onto the spacing grid, the trade-off between grid choice and multiplier drift, and auditing levels with the distinction threshold.
Contents
Color tokens showed a dependency: a role’s correctness was measured not on its own but together with the ground it fell on. In typography, the dependency moves inside a single role.
A text level is not a single number. If the record title’s size is 20 pixels, its line height, weight, and letter spacing are part of that decision too. Spreading these four fields across separate tokens and assembling them one by one in components lets wrong combinations get built, and the level as a whole appears nowhere. This lesson defines typography as a composite token and adds a second constraint — the line box has to fit onto the spacing scale’s grid.
Composite Tokens and the Grid Constraint
A composite token gathers more than one field under a single name. record-title is
not a size, it is a record: a size step, weight, line box, and letter spacing.
The reason for being composite is not consistency, it is validity. The Line Length and Spacing lesson showed that the leading multiplier cannot go below the typeface’s ascent-plus-descent sum. With size and multiplier as separate tokens, this condition has to be retested for every combination; in a composite token, it is tested once and recorded.
The second constraint comes from the spacing scale. The vertical measure a text block occupies is its line-box height; a card’s total height is the sum of these boxes and the spacing between them. Because the spacing scale is based on 4 pixels, totals drift off the grid if line boxes are not multiples of 4. The constraint is: the line box must be a multiple of the spacing scale’s base step. So the multiplier cannot be chosen freely; it is targeted, the box is rounded, and the actual multiplier falls out of that rounding.
// typography.mjs — composite typography tokens, fitting the line box to the grid, and the distinction audit const BASE = 16; // body size (px) const RATIO = 1.25; // ratio chosen in the Typographic Scale lesson const GRID = 4; // base step of the hybrid scale in the Spacing Scale lesson (px) const ASCENT_DESCENT = 1.051; // the typeface's declared ascent+descent (em); lower bound const TARGET_MULTIPLIER = 1.5; // leading multiplier chosen for body text const STEPS = [-1, 0, 1, 2, 3]; const size = (n) => Math.round(BASE * Math.pow(RATIO, n)); console.log("step size target box box fit to grid actual multiplier drift ascent-descent"); const BOX = {}; for (const n of STEPS) { const b = size(n); const target = b * TARGET_MULTIPLIER; const box = Math.round(target / GRID) * GRID; const multiplier = box / b; BOX[n] = box; console.log( `${String(n).padStart(4)} ${String(b).padStart(6)} ${target.toFixed(2).padStart(11)} ` + `${String(box).padStart(21)} ${multiplier.toFixed(4).padStart(14)} ${(multiplier - TARGET_MULTIPLIER).toFixed(4).padStart(7)} ` + `${(multiplier >= ASCENT_DESCENT ? "passed" : "FAILED").padStart(13)}` ); } // Grid choice is a trade-off: a coarse grid keeps totals tidy but widens multiplier drift. console.log("\ngrid largest multiplier drift multiplier range card total fits the grid"); for (const g of [1, 2, 4, 8]) { const multipliers = STEPS.map((n) => (Math.round((size(n) * TARGET_MULTIPLIER) / g) * g) / size(n)); const drift = Math.max(...multipliers.map((c) => Math.abs(c - TARGET_MULTIPLIER))); const box = Object.fromEntries(STEPS.map((n) => [n, Math.round((size(n) * TARGET_MULTIPLIER) / g) * g])); const cardTotal = 16 + box[1] + 8 + 2 * box[0] + 8 + box[-1] + 16; console.log( `${String(g).padStart(4)} ${drift.toFixed(4).padStart(22)} ${Math.min(...multipliers).toFixed(3)} - ${Math.max(...multipliers).toFixed(3)} ` + `${String(cardTotal).padStart(19)} ${(cardTotal % 4 === 0 ? "yes" : "NO").padStart(21)}` ); } // Composite tokens: a text level is not a single number, it is a four-field record. // Letter-spacing rule: 0 at the base step, -0.005em per step above, +0.005em per step below. const letterSpacing = (n) => -0.005 * n; const STYLES = [ { name: "page-title", step: 3, weight: 700, region: "page" }, { name: "section-title", step: 2, weight: 700, region: "page" }, { name: "record-title", step: 1, weight: 600, region: "record" }, { name: "body", step: 0, weight: 400, region: "record" }, { name: "body-strong", step: 0, weight: 600, region: "record" }, { name: "button-text", step: 0, weight: 600, region: "action" }, { name: "metadata", step: -1, weight: 400, region: "record" }, { name: "label", step: -1, weight: 600, region: "record" }, ]; console.log("\nstyle size weight line box multiplier letter spacing (em) letter spacing (px)"); for (const s of STYLES) { const px = size(s.step); const em = letterSpacing(s.step); console.log( `${s.name.padEnd(16)} ${String(px).padStart(4)} ${String(s.weight).padStart(7)} ${String(BOX[s.step]).padStart(9)} ` + `${(BOX[s.step] / px).toFixed(3).padStart(11)} ${em.toFixed(3).padStart(20)} ${(em * px).toFixed(3).padStart(19)}` ); } // Distinction audit: styles that appear together in the same region must be distinguishable. // The criterion is the Visual Hierarchy lesson's two-channel rule: size ratio >= 1.15 or // weight difference >= 200. const SIZE_THRESHOLD = 1.15; const WEIGHT_THRESHOLD = 200; console.log("\nregion style pair size ratio weight diff channel passed"); let weak = 0; for (const region of [...new Set(STYLES.map((s) => s.region))]) { const group = STYLES.filter((s) => s.region === region); for (let i = 0; i < group.length; i++) { for (let j = i + 1; j < group.length; j++) { const a = group[i]; const b = group[j]; const sizeRatio = Math.max(size(a.step), size(b.step)) / Math.min(size(a.step), size(b.step)); const weightDiff = Math.abs(a.weight - b.weight); const passed = []; if (sizeRatio >= SIZE_THRESHOLD) passed.push("size"); if (weightDiff >= WEIGHT_THRESHOLD) passed.push("weight"); if (!passed.length) weak++; console.log( `${region.padEnd(7)} ${(a.name + " / " + b.name).padEnd(32)} ${sizeRatio.toFixed(3).padStart(10)} ` + `${String(weightDiff).padStart(12)} ${passed.join("+") || "WEAK"}` ); } } } console.log(`pairs producing no distinction: ${weak}`); // Record card's vertical measure: line boxes and spacing steps must sum onto the grid. const SPACING = { "within-group": 8, "between-block": 16, "between-section": 24 }; const CARD = [ { kind: "spacing", name: "between-block" }, { kind: "style", name: "record-title", lines: 1 }, { kind: "spacing", name: "within-group" }, { kind: "style", name: "body", lines: 2 }, { kind: "spacing", name: "within-group" }, { kind: "style", name: "metadata", lines: 1 }, { kind: "spacing", name: "between-block" }, ]; console.log("\nrecord card vertical measure"); let total = 0; for (const p of CARD) { if (p.kind === "spacing") { total += SPACING[p.name]; console.log(` spacing ${p.name.padEnd(16)} ${String(SPACING[p.name]).padStart(5)} px`); } else { const s = STYLES.find((x) => x.name === p.name); const y = BOX[s.step] * p.lines; total += y; console.log(` text ${(p.name + " x" + p.lines).padEnd(16)} ${String(y).padStart(5)} px (box ${BOX[s.step]})`); } } console.log(` total ${String(total).padStart(5)} px fits the ${GRID} px grid: ${total % GRID === 0 ? "yes" : "NO"}`); // The same card if line boxes had not been fit to the grid. console.log("\nsame card with line boxes not fit to the grid"); let raw = 0; for (const p of CARD) { if (p.kind === "spacing") raw += SPACING[p.name]; else { const s = STYLES.find((x) => x.name === p.name); raw += size(s.step) * TARGET_MULTIPLIER * p.lines; } } console.log(` total ${raw.toFixed(1)} px fits the grid: ${raw % GRID === 0 ? "yes" : "NO"} drift: ${(total - raw).toFixed(1)} px`);
step size target box box fit to grid actual multiplier drift ascent-descent -1 13 19.50 20 1.5385 0.0385 passed 0 16 24.00 24 1.5000 0.0000 passed 1 20 30.00 32 1.6000 0.1000 passed 2 25 37.50 36 1.4400 -0.0600 passed 3 31 46.50 48 1.5484 0.0484 passed grid largest multiplier drift multiplier range card total fits the grid 1 0.0385 1.500 - 1.538 146 NO 2 0.0385 1.484 - 1.538 146 NO 4 0.1000 1.440 - 1.600 148 yes 8 0.2692 1.231 - 1.600 144 yes style size weight line box multiplier letter spacing (em) letter spacing (px) page-title 31 700 48 1.548 -0.015 -0.465 section-title 25 700 36 1.440 -0.010 -0.250 record-title 20 600 32 1.600 -0.005 -0.100 body 16 400 24 1.500 0.000 0.000 body-strong 16 600 24 1.500 0.000 0.000 button-text 16 600 24 1.500 0.000 0.000 metadata 13 400 20 1.538 0.005 0.065 label 13 600 20 1.538 0.005 0.065 region style pair size ratio weight diff channel passed page page-title / section-title 1.240 0 size record record-title / body 1.250 200 size+weight record record-title / body-strong 1.250 0 size record record-title / metadata 1.538 200 size+weight record record-title / label 1.538 0 size record body / body-strong 1.000 200 weight record body / metadata 1.231 0 size record body / label 1.231 200 size+weight record body-strong / metadata 1.231 200 size+weight record body-strong / label 1.231 0 size record metadata / label 1.000 200 weight pairs producing no distinction: 0 record card vertical measure spacing between-block 16 px text record-title x1 32 px (box 32) spacing within-group 8 px text body x2 48 px (box 24) spacing within-group 8 px text metadata x1 20 px (box 20) spacing between-block 16 px total 148 px fits the 4 px grid: yes same card with line boxes not fit to the grid total 145.5 px fits the grid: NO drift: 2.5 px
Rounding Spreads the Multiplier
The first table shows the constraint’s cost. The target multiplier is 1.5 at every step; the realized multipliers spread between 1.44 and 1.60.
The largest drift is at step 1: the 20-pixel text’s target box is 30 pixels, and rounded to 4 it becomes 32, pushing the multiplier to 1.60. At step 2, rounding goes down: the target box for 25 pixels is 37.5, the rounded box is 36, and the multiplier is 1.44.
The drift’s direction is not arbitrary; it depends on the size’s distance from the grid. If the size is a multiple of the grid — 16 is a multiple of 4 — the target box is a multiple too, and the drift stays at zero. The Typographic Scale lesson showed rounding distorting the neighboring-step ratio; here the same rounding shows its effect a second time, now on leading. The source is the same: fitting a continuous sequence onto an integer grid.
The last column gives an assurance. At none of the five steps does the multiplier drop below the 1.051 lower bound the typeface imposes. If that bound were crossed, the rounding would be unacceptable; grid conformance does not justify lines overlapping.
Grid Choice Is a Trade-off
The second table questions the constraint itself. Four candidate grids are compared on two criteria: multiplier drift and whether the card total fits the grid.
At the 1- and 2-pixel grids, multiplier drift stays small (0.0385), but the card total comes out to 146 pixels and misses the 4-pixel spacing grid. This is the fine grid’s real cost: line boxes look tidy, but component heights fall out of step with the spacing scale, and alignments drift once two components stack.
At the 8-pixel grid, the situation reverses. The card total fits (144), but the multiplier range opens up to between 1.231 and 1.600; the drift is 0.2692. A 1.231 multiplier means text is visibly cramped at one step of the same scale. A coarse grid tidies totals and breaks typography.
Four pixels is the only candidate that satisfies both criteria together: drift stays capped at 0.10, and the card total, 148, fits the grid. The reason for the choice is not aesthetics, it is the intersection of two constraints.
Letter Spacing Is a Size-Dependent Derivative
The third table gives the composite tokens’ full form. The letter-spacing column is not chosen one by one; it is derived from the step: zero at the base size, 0.005 em tighter per step above, 0.005 em looser per step below.
The rule’s rationale is optical. The same letter spacing looks wider at a large size, because the spacing grows with the size; at a small size, letters draw closer together and legibility drops. The derivation balances these two tendencies in opposite directions.
The pixel column makes the derivation’s result concrete: −0.465 pixels at the page title, +0.065 pixels at the metadata. Because the values are written in em, this conversion happens on its own and needs no recalculation when the size changes — the reason the token is defined in em rather than pixels, which would need a separate value for every step.
The Distinction Audit Runs Within a Region
The fourth table applies the Visual Hierarchy lesson’s two-channel rule to the style set. All eleven pairs are distinguished on at least one channel; no pair produces no distinction.
The audit’s region constraint is decisive here. The Typographic Scale lesson noted
that the neighbor check treats levels as an ordered list, and that hierarchy is
meaningful only among elements seen at the same time. This calculation turns that warning
into a rule: every style has a region, and the comparison runs only between pairs in the
same region. button-text stands alone in the action region and enters no pair; sharing
body’s size and weight is not a problem, because the two never compete in the same
visual space.
Two pairs are distinguished on the weight channel alone: body and body-strong,
metadata and label. Their size ratio is 1.000 — an acceptable design, but one that
carries a condition: the weight difference has to actually be renderable. The Typeface
Selection lesson showed a variable typeface producing intermediate weights; with a
static-weight family, if 600 is missing, the browser rounds it to 700, and label and
page-title collapse onto the same weight. A distinction resting on a single channel
depends on that channel actually existing.
Totals Close on the Grid
The last two blocks prove why the constraint was set. The record card is made of four text blocks and four spacing steps; with line boxes fit to the grid, the total is 148 pixels, a multiple of 4.
The same card, with line boxes left unrounded, comes to 145.5 pixels. A half-pixel remainder is invisible in a single card, but once ten cards stack in a results list, the remainder grows to five pixels and alignment with the filter panel beside them drifts. This is the vertical-axis counterpart of the problem the Grid Systems lesson showed on the horizontal axis: a measure that is not derived produces an error that accumulates as it repeats.
The card’s total height also delivers a side output: 148 pixels feeds the Density Decisions lesson’s records-per-screen calculation. That calculation stays fixed when typography tokens are fit to the grid; unfit, every card takes a slightly different height and the calculation becomes approximate.
Written Form
Composite tokens are written in style as a single class; the fields come from separate custom properties but are applied together.
:root { --step--1: 13px; --step-0: 16px; --step-1: 20px; --box--1: 20px; --box-0: 24px; --box-1: 32px; } .style-record-title { font-size: var(--step-1); line-height: var(--box-1); font-weight: 600; letter-spacing: -0.005em; } .style-body { font-size: var(--step-0); line-height: var(--box-0); font-weight: 400; letter-spacing: 0; }
Writing line height as pixels instead of a unitless multiplier is deliberate: a unitless multiplier is inherited and re-multiplied by a descendant’s own size, while a pixel value stays fixed when inherited. The grid constraint calls for a fixed box, so the box is declared in pixels. The cost is that if text of a different size appears inside a style class, the box will not fit it; the system forbids this as a rule — style classes do not nest.
How the component layer is built here is a decision too. A component token such as
record-title-size is opened only if the record card’s dense and sparse versions use
different steps. Where it is not opened, the component uses the style class directly, and
no setting point is produced.
Summary
- A text level is a composite token: size, weight, line box, and letter spacing are defined under a single name, because validity conditions are built across the fields.
- The line box must be a multiple of the spacing scale’s base step; the multiplier is targeted, the box is rounded, and the actual multiplier falls out of that rounding.
- Rounding spreads the target multiplier (with a 1.5 target, the realized multipliers spread between 1.44 and 1.60), and grid choice is the trade-off between this spread and the totals: a fine grid preserves the multiplier but shifts component totals, a coarse grid tidies totals but opens the multiplier range to 0.27.
- Letter spacing is derived from the step in em; using em needs no recalculation when the size changes.
- The distinction audit runs within a region; pairs distinguished on the weight channel alone depend on that weight actually existing in the typeface.
- With boxes fit to the grid, the card’s total height is 148 pixels and fits the grid; left unfit, it comes to 145.5 pixels, and the remainder accumulates as the card repeats.
Next Step
Line boxes are now fit to the spacing grid, but the grid itself is still defined in pixels. If a user increases the root font size, or the browser changes the scale, pixel values stay fixed and spacing does not grow while the text grows. The next lesson puts the measurement system in numbers: it converts spacing and size tokens to relative units, checks whether they produce integer pixels at different root sizes, and tests component heights against the touch-target criterion.
To keep your progress and take notes, Log in
My notes
Log in to take notes.