Lesson 09 / 19
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.
Contents
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.
// 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.
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.
: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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.