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

# Spacing and Size Tokens

Converting the spacing scale to relative units, preserving the text-to-space ratio when the root size changes, testing component heights against target-size thresholds, and deriving nested corner radii.

The previous lesson fit line boxes onto the spacing scale's 4-pixel grid and showed the
record card's total height closing on the grid. All of that calculation was in pixels.

Pixels carry a problem. When a user increases their browser's root font size, text
grows; spacing written in pixels does not. The interface turns into a mix of grown text
and fixed spacing, breaking the balance the Density Decisions lesson established. This
lesson puts numbers to the measurement system: it ties unit choice to a calculation,
derives component heights from scale steps, and audits whether the derivation meets
accessibility thresholds.

## Unit Is Not a Presentation Detail

The spacing scale is a sequence of numbers; which unit it is written in is not part of
the scale, it is part of the scale's behavior. There are two candidates.

**Pixels** are fixed. Whatever the root size, 16 pixels is 16 pixels, which makes
design-time calculations easy: grid conformance holds under every condition.

A **relative unit** is a multiple of the root font size. When the root is 16 pixels, 1
unit is 16 pixels; once the root rises to 20 pixels, it is 20 pixels. The scale inherits
the user's font-size preference automatically.

The choice is not a preference, it is a calculation's result. The program below compares
the two regimes on the same card, then derives component measures and audits them
against thresholds.

```js
// scale.mjs — numbering spacing and size tokens, unit choice, and derived measures

const ROOT_BASE = 16; // default root font size (px)
const SCALE = [4, 8, 12, 16, 20, 24, 32, 48, 64, 96];

console.log("step  px  rem      root 16 px  root 18 px  root 20 px  integer (18)  integer (20)");
for (let i = 0; i < SCALE.length; i++) {
  const px = SCALE[i];
  const rem = px / ROOT_BASE;
  const r18 = rem * 18;
  const r20 = rem * 20;
  console.log(
    `${String(i + 1).padStart(4)} ${String(px).padStart(3)} ${rem.toFixed(4).padStart(7)} ` +
      `${px.toFixed(2).padStart(10)} ${r18.toFixed(2).padStart(10)} ${r20.toFixed(2).padStart(10)} ` +
      `${(Number.isInteger(r18) ? "yes" : "no").padStart(14)} ${(Number.isInteger(r20) ? "yes" : "no").padStart(14)}`
  );
}

// Unit choice: when the root size grows, do text and space grow together?
// Card: top space + title box + within-group + two lines of body + within-group + metadata + bottom space
const CARD = { topSpace: 16, titleBox: 32, withinGroup: 8, bodyBox: 24, bodyLines: 2, metadataBox: 20, bottomSpace: 16 };
function card(root, spacingRem) {
  const scale = root / ROOT_BASE;          // text always scales with the root
  const factor = spacingRem ? scale : 1;   // spacing scales if rem, stays fixed if px
  const text = (CARD.titleBox + CARD.bodyBox * CARD.bodyLines + CARD.metadataBox) * scale;
  const space = (CARD.topSpace + CARD.withinGroup * 2 + CARD.bottomSpace) * factor;
  return { text, space, total: text + space, ratio: text / space };
}

console.log("\nunit  root  text (px)  space (px)  total (px)  text/space ratio");
for (const [name, isRem] of [["px ", false], ["rem", true]]) {
  for (const root of [16, 18, 20, 24]) {
    const c = card(root, isRem);
    console.log(
      `${name}  ${String(root).padStart(3)} ${c.text.toFixed(1).padStart(11)} ${c.space.toFixed(1).padStart(12)} ` +
        `${c.total.toFixed(1).padStart(12)} ${c.ratio.toFixed(3).padStart(19)}`
    );
  }
}

// Component height is derived: line box + 2 x vertical padding + 2 x border width.
// Thresholds: WCAG 2.5.8 Target Size (Minimum) 24 px, WCAG 2.5.5 Target Size (Enhanced) 44 px.
const MIN = 24;
const ENHANCED = 44;
const height = (box, padding, border) => box + 2 * padding + 2 * border;

const COMPONENTS = [
  { name: "button", box: 24, padding: 8, border: 1 },
  { name: "button-large", box: 24, padding: 12, border: 1 },
  { name: "text-field", box: 24, padding: 8, border: 1 },
  { name: "icon-button", box: 16, padding: 8, border: 1 },
  { name: "label", box: 20, padding: 4, border: 0 },
  { name: "list-row", box: 24, padding: 12, border: 0 },
];
console.log("\ncomponent      box  padding  border  height  2.5.8 (24)  2.5.5 (44)");
for (const c of COMPONENTS) {
  const y = height(c.box, c.padding, c.border);
  console.log(
    `${c.name.padEnd(14)} ${String(c.box).padStart(4)} ${String(c.padding).padStart(9)} ${String(c.border).padStart(6)} ` +
      `${String(y).padStart(8)} ${(y >= MIN ? "passed" : "FAILED").padStart(11)} ${(y >= ENHANCED ? "passed" : "FAILED").padStart(11)}`
  );
}

// Can the 44 px threshold be hit exactly with scale steps?
console.log("\nbox 24, border 1 -> padding step  height  2.5.5");
for (const padding of SCALE) {
  const y = height(24, padding, 1);
  if (y > 60) break;
  console.log(`${String(padding).padStart(33)} ${String(y).padStart(8)} ${(y >= ENHANCED ? "passes" : "misses").padStart(7)}`);
}
console.log("same calculation with border 0:");
for (const padding of SCALE) {
  const y = height(24, padding, 0);
  if (y > 60) break;
  console.log(`${String(padding).padStart(33)} ${String(y).padStart(8)} ${(y >= ENHANCED ? "passes" : "misses").padStart(7)}`);
}

// Nested corner radius: inner radius = outer radius - the padding between them.
// A fully round radius (999) is an exception: it does not pass to the inner element, which takes its own radius.
const RADIUS_SCALE = [0, 4, 8, 12, 999];
console.log("\nnested pair                 outer radius  padding  computed  used  in scale");
for (const [name, outer, padding] of [
  ["card / body", 8, 16],
  ["card / cover image", 8, 4],
  ["popover / title  ", 12, 8],
  ["popover / button", 12, 4],
  ["label / counter badge", 999, 4],
  ["button / icon frame", 4, 2],
]) {
  const computed = outer === 999 ? 999 : outer - padding;
  const used = outer === 999 ? 999 : Math.max(0, computed);
  console.log(
    `${name.padEnd(26)} ${String(outer).padStart(11)} ${String(padding).padStart(10)} ${String(computed).padStart(11)} ` +
      `${String(used).padStart(11)}  ${RADIUS_SCALE.includes(used) ? "yes" : "NO"}`
  );
}

// Icon scale is derived not from typography steps, but from line boxes.
console.log("\nline box  aligned icon size  in scale");
const ICON = [12, 16, 20, 24, 32];
for (const box of [20, 24, 32, 36, 48]) {
  // The icon is centered inside the line box; an icon larger than 2/3 of the box overflows the line.
  const target = Math.floor(((2 / 3) * box) / 4) * 4;
  console.log(
    `${String(box).padStart(9)} ${String(target).padStart(19)} ${(ICON.includes(target) ? "yes" : "NO").padStart(9)}`
  );
}
```

```
step  px  rem      root 16 px  root 18 px  root 20 px  integer (18)  integer (20)
   1   4  0.2500       4.00       4.50       5.00             no            yes
   2   8  0.5000       8.00       9.00      10.00            yes            yes
   3  12  0.7500      12.00      13.50      15.00             no            yes
   4  16  1.0000      16.00      18.00      20.00            yes            yes
   5  20  1.2500      20.00      22.50      25.00             no            yes
   6  24  1.5000      24.00      27.00      30.00            yes            yes
   7  32  2.0000      32.00      36.00      40.00            yes            yes
   8  48  3.0000      48.00      54.00      60.00            yes            yes
   9  64  4.0000      64.00      72.00      80.00            yes            yes
  10  96  6.0000      96.00     108.00     120.00            yes            yes

unit  root  text (px)  space (px)  total (px)  text/space ratio
px    16       100.0         48.0        148.0               2.083
px    18       112.5         48.0        160.5               2.344
px    20       125.0         48.0        173.0               2.604
px    24       150.0         48.0        198.0               3.125
rem   16       100.0         48.0        148.0               2.083
rem   18       112.5         54.0        166.5               2.083
rem   20       125.0         60.0        185.0               2.083
rem   24       150.0         72.0        222.0               2.083

component      box  padding  border  height  2.5.8 (24)  2.5.5 (44)
button           24         8      1       42      passed      FAILED
button-large     24        12      1       50      passed      passed
text-field       24         8      1       42      passed      FAILED
icon-button      16         8      1       34      passed      FAILED
label            20         4      0       28      passed      FAILED
list-row         24        12      0       48      passed      passed

box 24, border 1 -> padding step  height  2.5.5
                                4       34  misses
                                8       42  misses
                               12       50  passes
                               16       58  passes
same calculation with border 0:
                                4       32  misses
                                8       40  misses
                               12       48  passes
                               16       56  passes

nested pair                 outer radius  padding  computed  used  in scale
card / body                          8         16          -8           0  yes
card / cover image                   8          4           4           4  yes
popover / title                     12          8           4           4  yes
popover / button                    12          4           8           8  yes
label / counter badge              999          4         999         999  yes
button / icon frame                  4          2           2           2  NO

line box  aligned icon size  in scale
       20                  12       yes
       24                  16       yes
       32                  20       yes
       36                  24       yes
       48                  32       yes
```

## A Relative Unit Does Not Give Integer Pixels

The first table shows the cost of switching to a relative unit. The scale steps are
integers at a 16-pixel root, and integers at a 20-pixel root too. At an 18-pixel root,
four steps produce half pixels: 4.50, 13.50, 22.50, and the step below them.

This is not a defect, it is the consequence of what a relative unit is: at a root size
that is not a multiple of 16, the scale produces fractions of a division by 16. The
result: **grid conformance is an invariant of design time, not of runtime.** The designer
works on a 4-pixel grid; the user sees a scaled version of that grid at their own root
size, and because the grid's ratios are preserved, the visual result is not broken.

Half-pixel values by themselves cause no problem; the paint stage rounds them at its own
resolution. The problem appears only where one alignment is computed two different ways —
if one edge is in pixels and its neighbor is a relative unit, the roundings can go in
different directions. The rule follows: **mixed units are not used on a single axis.**

## Fixed Spacing Breaks Density

The second table gives the actual decision. The same card is measured in two regimes, at
four different root sizes.

In the pixel regime, text height grows with the root — from 100 to 150 pixels — but space
stays fixed at 48. The text-to-space ratio rises from 2.083 to 3.125, a 50 percent
increase: when the user enlarges the text, the interface does not grow proportionally, it
**compresses**.

In the relative-unit regime, the ratio is 2.083 at all four root sizes: the card grows
and the ratios are preserved. This is also what preserves the grouping-ratio criterion
from the Proximity and Grouping lesson — the ratio between within-group spacing and a
group boundary stays independent of root size.

This takes unit choice out of the realm of preference. Spacing tokens are written in a
relative unit, because spacing's job is not to state an absolute distance, it is to
**establish a ratio**, and the unit that preserves a ratio is the relative one.

Exceptions are recorded. Border width, focus-ring width, and hairline rules stay in
pixels: their job is drawing a visible boundary, not establishing a ratio, and they need
not grow with the root size.

## Component Height Is Not Chosen, It Is Derived

The third table shows where size tokens come from: a component's height is not defined as
a token, it is computed from three tokens.

$$
\text{height} = \text{line box} + 2 \cdot \text{vertical padding} + 2 \cdot \text{border width}
$$

The rationale is the same as the Grid Systems lesson's column-width relation: writing a
dependent quantity as though it were an independent decision produces inconsistency at
the first change. If the button's height is written as 42 pixels, 42 becomes wrong the
moment the line box changes, and nobody notices.

The audit runs against two thresholds. All six components clear the 24 pixels WCAG 2.5.8
requires; only two clear the 44 pixels 2.5.5 requires. The standard button stays at 42
pixels — two pixels below the threshold.

## Two Pixels Cannot Be Closed with a Scale Step

The fourth block shows why. With the line box fixed at 24 and the border at 1, padding is
chosen from the scale steps: step 8 produces 42, and the next step, 12, jumps straight to
50. The value in between, 44, has no counterpart on the scale. Reducing the border to
zero does not solve it either: step 8 gives 40, step 12 gives 48.

Three options remain, and all three carry a cost. The first is raising padding to 12 and
growing the button to 50 pixels, which lowers the Density Decisions lesson's
records-per-screen count. The second is adding a 10-pixel step to the scale; the Spacing
Scale lesson showed that the gap between 8 and 10 sits below the distinction threshold,
so the scale would gain a step with no payoff. The third is leaving the visual size at 42
and expanding the hit area to 44; the Screen Size and Input Type lesson calculated that
this needs the spacing between buttons to grow as well.

At the system level, the right decision is the third, and it is recorded: the standard
button appears at 42 pixels, its hit area is 44 pixels, and the spacing between buttons
is chosen from the 20-pixel step at minimum. This is the second example of a token valid
not on its own but together with its neighbor; the first was color pairings.

## Radius and Icon Scale Are Derived Too

The fifth table computes nested corner radii. The inner radius comes from subtracting the
padding between the two curves from the outer radius; a negative result drops to zero,
keeping the two curves concentric. The card's body sits 16 pixels inside, so its radius
is zero; the cover image sits 4 pixels inside, so its radius is 4.

One row falls outside the scale: the icon frame inside the button wants a 2-pixel radius,
and 2 is not on the radius scale. The decision runs two ways — either the frame's padding
rises to 4 and the radius drops to zero, or 2 is added to the scale. The Repetition and
Consistency lesson measured small values in corner radius as indistinguishable, and that
finding points to the first option.

A fully round radius is defined as an exception and does not enter the derivation. The
badge inside it is also fully round; subtraction is meaningless here, because fully round
is not a radius, it is a rule.

The last table shows the icon scale derived not from typographic steps, but from line
boxes. An icon is centered inside the line box and must not exceed two-thirds of it;
beyond that, it overflows above and below the line. All five icon sizes computed for the
five line boxes find a match on the scale, because both the boxes and the icon scale are
fit to the 4-pixel grid.

## Written Form

Spacing and size tokens are written in two layers; component heights are never written,
they are obtained by calculation.

```css
:root {
  /* primitive: scale steps, in relative units */
  --spacing-1: 0.25rem;
  --spacing-2: 0.5rem;
  --spacing-3: 0.75rem;
  --spacing-4: 1rem;
  --spacing-6: 1.5rem;

  /* primitive: measures that stay in pixels */
  --border-thin: 1px;
  --border-thick: 2px;
  --radius-1: 4px;
  --radius-2: 8px;

  /* semantic */
  --spacing-within-group: var(--spacing-2);
  --spacing-between-block: var(--spacing-4);
  --spacing-between-section: var(--spacing-6);
}

.button {
  --button-padding-y: var(--spacing-within-group);
  --button-padding-x: var(--spacing-between-block);

  padding: var(--button-padding-y) var(--button-padding-x);
  border: var(--border-thin) solid currentColor;
  border-radius: var(--radius-1);
  line-height: 24px;
}
```

The button's height appears nowhere in this declaration. Height falls out of the sum of
the line box and the padding, and whether that sum reaches 44 pixels is the audit's job.
A fixed height would let content overflow once text wraps or the root size grows.

## Summary

- Unit choice is not a presentation detail, it is a decision that determines the scale's
  behavior: a relative unit inherits the user's root font-size preference.
- A relative unit does not give integer pixels at every root size; grid conformance is an
  invariant of design time, not of runtime. Mixed units are not used on a single axis.
- In a card whose spacing is written in pixels, when the root rises from 16 to 24 the
  text-to-space ratio climbs from 2.083 to 3.125; in a relative unit, the ratio stays
  2.083 at all four root sizes.
- Border width, focus ring, and hairline rules stay in pixels, because their job is to
  draw a boundary, not establish a ratio.
- Component height is not written as a token; it is derived from the line box, padding,
  and border width, and audited against target-size thresholds. The standard button
  stays at 42 pixels, and scale steps cannot hit the 44 threshold exactly; the decision
  is to keep the visual size and expand the hit area and the neighboring spacing.
- Corner radius in nested elements is derived by subtraction; fully round is not a value
  but a rule and does not enter the derivation. Icon scale is derived from the line box,
  not the typographic step.

## Next Step

Scale steps, typography, and size tokens were built on the assumption of a single
viewport. When the interface changes width, column count, margin, and gutter change too,
and where that change happens has so far been a set of decisions given one at a time. The
next lesson turns breakpoints into a token set, audits the column arithmetic at every
breakpoint, tests spacing consistency, and reports breakpoints that change no layout
decision.
