Skip to content
academia.sh

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.

Start typing to search.

↑↓ Esc navigate · open · close