---
title: 'Typography Tokens'
source: 'https://academia.sh/en/courses/design-systems/typography-tokens'
course: 'Design Systems'
language: en
updated: '2026-08-19T05:19:56+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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.

```css
: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.
