Skip to content

8 · Type and space

None of this can be solved.

There is no contrast requirement to invert for "how big is a heading". No standard says a card must have 16px of padding. So the honest thing a design system can do here is different: state where each number came from, so the next person can argue with the reasoning instead of guessing at the intent.

That is a lower bar than the colour chapters and it is still higher than most systems clear.

Modular scales

A modular scale is a base size and a ratio. Multiply repeatedly and you have your sizes.

Ratios come from musical intervals, which is a nice piece of history and no argument at all for using any particular one:

Ratio Name 16px base, going up
1.2 minor third 16, 19.2, 23.0, 27.6, 33.2
1.25 major third 16, 20, 25, 31.3, 39.1
1.333 perfect fourth 16, 21.3, 28.4, 37.9, 50.5
1.5 perfect fifth 16, 24, 36, 54, 81
1.618 golden 16, 25.9, 41.9, 67.8, 109.7

Every type tutorial recommends 1.333 or 1.5, and every one of those tutorials is demonstrating on a page with one heading and one paragraph.

Why 1.2 and not 1.5

Count the sizes a real interface needs inside one card: a title, a section label, body text, a caption, and a micro label for legal or metadata. That is five, and they all have to be distinguishable from each other and readable.

five sizes at a 1.5 ratio

Billing

Current plan

Pro, £29 a month

Renews on 4 August. You can change or cancel at any time.

VAT included where applicable · 7.1px would be the next step down

the same five at a 1.2 ratio

Billing

Current plan

Pro, £29 a month

Renews on 4 August. You can change or cancel at any time.

VAT included where applicable · 11.1px would be the next step down

Five steps of 1.5 span 10.7px to 54px — a 5× range inside one card, and the next step down is 7px, which is not text. Five steps of 1.2 span 13.3px to 27.6px, and every one of them is usable.

A big ratio runs out of room by the third size. It looks better in a specimen and falls apart in a dense interface. That is the entire argument for 1.2, and it is a judgement, not a measurement — but it is a judgement you can now check against your own densest screen.

Fluid, so nothing jumps

A fixed scale needs breakpoints, and at every breakpoint the type visibly snaps. clamp() removes them:

--text-4: clamp(1.375rem, 1.26rem + 0.57vw, 1.75rem);
/*              ↑ floor   ↑ grows with the viewport  ↑ ceiling */

vw is 1% of the viewport width, so the middle term rises smoothly as the window widens and the two ends stop it going anywhere silly. nilam's steps are fluid between a 360px and a 1280px viewport — the narrowest phone worth supporting and a comfortable laptop.

the shipped scale — resize the window and watch it move

--text-6  page title

--text-5

--text-4

--text-3  section heading

--text-2

--text-1  body, the default

--text-0  secondary body

--text-00  captions, meta

--text-000  micro

Why rem is an accessibility requirement

rem means "relative to the root font size". px means "this many pixels, whatever you asked for".

Here is the distinction people get wrong. Browser zoom scales px too — pinch-zoom and Ctrl+= work fine on a px layout. But the default font size setting in browser preferences is a different mechanism, and it is the one a lot of people with low vision actually use, because it enlarges text without making every layout horizontally scrollable. That setting changes the root font size, and only rem follows it.

So font-size: 14px is not a neutral choice. It is a decision to override a preference the user has explicitly stated.

WCAG 1.4.4 Resize Text requires content to work at 200% without loss of content or function. And there is a trap right at the top of the stylesheet:

html { font-size: 14px }   /* this breaks every rem below it */

Setting a pixel root size disables the user's setting for the whole document, including everything expressed in rem. nilam.base.css notes this against the rule it does set: -webkit-text-size-adjust: 100%, with a comment that reads "1.4.4 Resize Text: never set a px root size."

The same applies to spacing. nilam's space scale is a 4px grid expressed in rem — 0.25rem, 0.5rem, 0.75rem, 1rem — so when a reader doubles their font size, the padding grows with it and a control does not end up with text pressed against its edges.

The 65-character measure

--measure: 65ch;

ch is the width of the 0 glyph in the current font, so 65ch is about 65 characters — call it ten to twelve words a line.

This is the one typographic number with real literature behind it. The tradition puts comfortable prose at 45–75 characters, with 66 often quoted as the target. The reason is the return sweep: at the end of a line your eye jumps back and down, and a very long line makes it easy to land on the wrong one, while a very short line breaks the rhythm with too many jumps.

no measure — as wide as its container

A prover measures separation, not appropriateness. An earlier revision of nilam optimised separation until danger resolved to magenta, with every assertion passing, and "Delete account" looked like a fashion brand rather than a warning. The hue windows in the solver exist because of it, and the class of error is only visible by rendering.

the same paragraph at var(--measure)

A prover measures separation, not appropriateness. An earlier revision of nilam optimised separation until danger resolved to magenta, with every assertion passing, and "Delete account" looked like a fashion brand rather than a warning. The hue windows in the solver exist because of it, and the class of error is only visible by rendering.

On a wide screen the first one is a genuinely tiring read and it is not obvious why. That is the effect a measure removes.

Two rules that are the same rule backwards

Line height falls as size rises. 1.6 is right for body text and looks like a gap on a display heading. 1.2 is right for a heading and is unreadable as body.

Tracking rises as size falls. Letter-spacing at 0 looks cramped on 11px uppercase text, which is why every well-set interface tracks its micro labels wide — and it looks loose on a 48px heading, which is why nilam's headings carry -0.02em.

tracking, wrong then right

micro label at 0 tracking

micro label at --tracking-micro

leading, wrong then right

A heading at 1.75 leading
opens a hole in the page

A heading at 1.15 leading
holds together as one object

Space, radius, lines, motion

Space is a 4px grid in rem, doubling from --space-3 up. Small increments matter inside a control — 4px versus 8px of padding is a visible difference on a button. Past 1rem they stop mattering: nobody can tell 20px from 24px of gap between two cards, so offering both only invites two developers to choose differently.

--space-0 to --space-7

space-0 2px
space-1 4px
space-2 8px
space-3 12px
space-4 16px
space-5 24px
space-6 32px
space-7 48px

Radii have to nest. An inner radius should be the outer radius minus the padding between them. Get it wrong and the corners look off in a way people notice and cannot name:

left: inner radius = outer − padding. right: both the same

correct
wrong

The right-hand corners run out of parallel. This is why nilam's radius scale has small gaps at the bottom — that is where nesting actually happens.

A hairline is a token. --line-1: 1px exists as a name so that a future decision about what a hairline should be on a 3× display happens in one place, rather than a 3× screen rendering three device pixels of border everywhere.

Motion is short. --dur-1 is 150ms, which is roughly where a UI transition stops reading as "responding" and starts reading as "loading". The longer durations are only for things that travel a distance — a drawer, a dialog.

And the focus ring's offset is not cosmetic. WCAG 1.4.11 wants the ring to have 3:1 against adjacent colours. A ring drawn flush against a filled button of similar lightness has the button as its adjacent colour and fails; a 2px gap guarantees the adjacent colour is the page.

tab into these — the ring has an offset

A link

How nilam handles it

nilam/scale.css is hand-authored, and it says so at the top. What it does instead of solving is record the reasoning beside every number. The full table is on Everything that is not a colour; these are the lines worth reading first:

Token group The reasoning in one line
--text-* a 1.2 minor third, fluid between 360px and 1280px, because a big ratio runs out of room by the third size inside one card
--measure 65ch, the one typographic number with real literature behind it
--space-* a 4px grid in rem, doubling from --space-3 up
--r-* small gaps at the bottom, because that is where nesting happens
--dur-* 150ms is where a transition stops reading as "responding"
--ring-offset the gap guarantees the adjacent colour is the page, for 1.4.11

The failure that shows why writing the reasoning down matters

--text-display's own comment says the display size wants weight 200 — large type gets its presence from size, and adding weight to both is why "bold and big" reads as shouting rather than as confidence.

The first version of the file then shipped nothing below weight 400.

So the one treatment the scale explicitly recommended was inexpressible using the scale's own tokens. It was found by an application that had to declare --weight-thin locally in order to follow the advice it had been given. --weight-thin: 200 exists because of that.

The lesson generalises past type: a token set has to be checked against its own documentation, because a recommendation and the means to follow it are two different artefacts and nothing automatically keeps them together.

Next: the cascade — specificity, !important, and what @layer changed.

Reference: Everything that is not a colour · What the token export does not carry, because --tracking-* and --measure do not survive the trip to other platforms.