The scale curve: how the largest role gets capped, and why
This module derives every text size from two numbers: $scale-base (the
size of body text) and $scale-ratio (the multiplier between adjacent
roles). This document explains a specific problem with that derivation at
the large end of the scale, why the obvious fixes don’t work, and the
mathematical reasoning behind the fix this module actually ships.
The problem: geometric growth doesn’t know when to stop
Every role’s size is $scale-base * $scale-ratio ^ step, where step is
the role’s fixed position in the scale — fine at -2, caption at -1,
body at 0, then lead/h6 through h1 and display climbing from step 1
to step 7, ten tiers in total. This is ordinary geometric growth, and it compounds: a
ratio that looks entirely reasonable over two or three steps can produce an
absurd result by the seventh.
Concretely: take a real body size of 18.5px. The golden ratio (1.618) has
genuine typographic pedigree and is a common choice for a type scale. Run it
through this module’s ten-tier range and display comes out to 537px
— larger than most hand-drawn hero type, and larger than a designer working
on the same project by hand had chosen for the same role (72px), by more
than a factor of seven. Nothing about the golden ratio is wrong in
isolation. The problem is that no relationship is enforced between how wide
a ratio is and how many steps it has to survive, so a wide ratio and a long
step range compound into a size nobody would have picked directly.
Why the two obvious fixes don’t work
Refuse to compile past some threshold. The simplest response is a hard error: pick a maximum plausible size, and refuse to build if any role exceeds it. This has a real cost — it makes a whole family of otherwise legitimate ratios (the golden ratio among them) simply unusable, forcing a consumer to abandon a ratio they may have chosen deliberately rather than letting the module do something sensible with it. A ratio should render something, even if the raw arithmetic needs shaping.
Clamp every role into a fixed band. The next instinct is to cap every
role’s size into some band around body text — say, never let anything
render more than some multiple of body size, in either direction, and use
math.clamp() to enforce it. This sounds safe, but it breaks something
subtle: a hard clamp has no memory of how far below the ceiling a value
already was. Two roles that would naturally land at different sizes — say
4.8x and 3.8x of body size — both get pulled up to the same ceiling the
instant either one crosses it, and render identically, even though the
scale’s distinct tiers exist specifically so that never happens. Verified
directly: applying a uniform clamp at this module’s own shipped default
ratio (1.25) makes display and h1 compile to the exact same pixel value.
A mechanism that silently collapses two roles into one, at the module’s own
default configuration, is worse than the problem it was meant to solve.
A narrower version of the same clamp — bounding only body-adjacent roles,
leaving headings alone — fixes the collision but not the actual problem.
This is worth stating plainly because it’s a natural next attempt: restrict
the clamp to body, its immediate neighbors, and leave the large headings
(h1 through display) completely unbounded. That does stop the collision
(no two roles land on the same pixel value), but it leaves the original
complaint fully unresolved — the golden ratio still produces a 537px
display, uncapped, because nothing in the vision-science research that
motivated the body-adjacent bound (below) actually says anything about
scanned, single-line headings. It just stops being a compile error while
staying exactly as large as before.
Why that research doesn’t govern heading size, and what it does govern.
A well-known finding in reading psychophysics (Legge & Bigelow, 2011,
Does print size matter for reading? A review of findings from vision
science and typography, Journal of Vision 11(5):8 — cited throughout
this document as C28) establishes a “fluent range”: print sizes over which
continuous reading holds its maximum speed, spanning a factor of 10 in
angular x-height, corroborated against the actual range of body-text sizes
used across real books and publications. That is a finding about sustained
reading — the kind of reading body text and captions get. It says nothing
about how large a scanned, single line of display type may run relative to
body text within one document’s type scale; whether a ratio-based type scale
even produces better results than hand-picked steps has never been studied
at all (a separate finding this module cites as C16), and the aesthetic
appeal some designers attribute to the golden ratio specifically has not
been shown to transfer to typographic outcomes (C17). Using the reading-
fluency research to bound the relationship between roles was the wrong
question to ask it. Using its own stated absolute upper bound as a size
ceiling — a different, much narrower claim — turns out to hold up, and is
what this module actually does.
The design: a curve, not a clamp
The fix has two parts: a fixed ceiling no role may exceed, and a curve that bends toward it only when — and only as much as — it actually needs to.
The curve
For a role whose ordinary geometric formula would produce a raw value x = $scale-base * $scale-ratio ^ step, let T be a fixed threshold and C be
the ceiling. The size this module actually emits is:
size(x) = x for x <= T
size(x) = T + (x - T)(C - T) / (C + x - 2T) for x > TBelow the threshold, this is exact identity — nothing changes from the
plain geometric formula. Above it, the curve bends smoothly toward C,
approaching it without ever exactly reaching it, no matter how far past T
the raw value runs. Critically, the two branches meet with matching
slope as well as matching value at x = T — there is no visible kink
where the curve starts, because the derivative of the second branch at
x = T works out to exactly 1, identical to the first branch’s constant
slope of 1. This is the same mathematical shape an audio compressor’s
“soft knee” uses: transparent below a threshold, a smooth transition into a
ceiling above it, rather than a hard limiter that clips instantly.
The formula applies uniformly to every role’s own raw value — there is no
special-casing by role. A role whose particular ratio never pushes its raw
value past T is untouched, whatever role it is; a role that does gets
compressed in proportion to how far past T it would otherwise have gone.
Two properties this shape was specifically chosen for, and two shapes that were tried and rejected for lacking them:
- A curve engineered to land exactly on the ceiling at a specific, predetermined step turns out to have zero sensitivity to the ratio the instant it engages — every ratio wide enough to trigger it produces the identical value at that step, no matter how much wider it gets from there. That is not a smoothly capped scale; it is a scale that goes flat the moment it starts compressing. The formula above deliberately gives up landing on an exact number in exchange for staying responsive to the ratio at every point along the curve.
- A curve applied to every role unconditionally, with no threshold, ends up
compressing values that were never going to exceed the ceiling in the
first place — verified directly against this module’s own shipped
default, where an unthresholded version of this curve made
displaylarger than the plain formula already produced, not smaller. The threshold exists specifically so the curve is a strict no-op until it’s actually needed.
The threshold, T
T = $scale-base * 1.25 ^ 7 — the size the largest role would take under
this module’s own shipped default ratio (1.25), computed against whatever
$scale-base a given project actually uses. The 1.25 here is a fixed
reference point, not whatever ratio a project has chosen: it guarantees
that the module’s own default configuration is a complete no-op (any ratio
at or below 1.25 renders exactly what the plain geometric formula always
produced), while any ratio that would push the largest role past what 1.25
already produces starts engaging the curve, in proportion to how far past
it goes.
The ceiling, C
The number every wide ratio ultimately bends toward, and the one that took the most care to derive honestly.
The threshold has its own precondition: it must stay below the ceiling
T scales with $scale-base; C does not — it is a fixed constant (see
“Deriving the ceiling”, below). The curve’s formula, T + (x - T)(C - T) / (C + x - 2T), divides by (C + x - 2T), and its whole shape — approaching
C from below, matching slope with the identity branch at x = T — depends
on (C - T) being positive. Nothing about $scale-base on its own keeps
that true: it is an ordinary configuration input, not one this module
otherwise bounds from above, and T is $scale-base * 1.25^7 — the
7th-power compounding is the same mechanism that makes a wide $scale-ratio
run away, applied here to the module’s own fixed reference ratio instead of
the consumer’s.
Concretely, a project setting $scale-base: 1.5rem (a 24px body size,
unremarkable on its own) puts T at 7.1526rem — already past the
6.410256rem ceiling — with no unusual $scale-ratio involved at all. Past
that line (C - T) goes negative and the curve stops being the shape
described above: the compiled scale can stop being monotonic (a smaller
step rendering larger than a bigger one), or the arithmetic can run toward
NaN and crash several files away, inside the leading derivation, with a
message that names neither $scale-base nor why.
The exact breakpoint is C / 1.25^7, 1.344328...rem (21.5093...px at a
16px root). The module refuses to compile at or past it — a module-load
guard in settings/_roles.scss, next to this file’s other input guards —
naming the offending $scale-base and this precondition, rather than
letting either failure mode surface downstream. This is a guard on
$scale-base, not on $scale-ratio: the “a ratio is never refused for its
magnitude alone” principle earlier in this document is about that other
axis, and does not extend to this one.
Deriving the ceiling
What’s actually measured. C28’s fluent range tops out at 2° of
angular x-height. The paper itself converts this to a physical size, at its
own explicitly stated assumption of a 40cm (16in) reading distance: 14mm,
which it states as 40pt of x-height. This isn’t a figure this module
derived — it’s quoted directly from the paper — but the unit conversion was
checked independently before trusting it: 40pt × 0.352778mm/pt = 14.111mm, matching the paper’s own “~14mm” closely.
What’s assumed on top of it, and why. C28 bounds x-height, not
font-size, and x-height is not a fixed proportion of a typeface’s em-size —
it varies by design. No research finding this module draws on specifies
that ratio, so converting x-height into a usable CSS length requires one
stated, adjustable assumption. Real x-height-to-em ratios, checked against
the W3C CSS2.1 specification (§15.2.4, font-size-adjust) and an
independently measured reference table (Jukka Korpela,
jkorpela.fi/x-height.html):
| Typeface | x-height/em | Source |
|---|---|---|
| Times New Roman | ~0.45–0.46 | W3C CSS2.1 / Korpela |
| Georgia | ~0.48 | Korpela |
| Arial / Helvetica | ~0.52 | Korpela |
| Verdana | ~0.55–0.58 | Korpela / W3C CSS2.1 |
Real typefaces span roughly 0.45 to 0.58 on this measure — a swing of about ±15% on the final ceiling depending entirely on which typeface is assumed. Since this module ships for the web, where interface typefaces skew toward the higher end of that range (Verdana was designed explicitly for small-screen legibility; Arial and common system-font stacks cluster near it) rather than the lower end typical of classic print serifs, the figure used here is 0.52 (Arial/Helvetica) — a stated, web-appropriate choice, not a universal constant. A project targeting a different typeface tradition would reasonably land on a different number here; this is the one genuine judgment call in the whole derivation, and it’s called out as one rather than hidden inside a single combined figure.
The rest is arithmetic:
x-height: 40pt (the measured figure, above)
font-size: 40pt / 0.52 = 76.9231pt (the stated assumption applied)
pixels: 76.9231 * (96/72) = 102.5641px (unit conversion, @96dpi)
rem: 102.5641 / 16 = 6.410256rem (unit conversion, @16px root)The ceiling: 6.410256rem, about 102.56px at a standard 16px root font
size. This is best understood as a measured bound with one clearly
documented typeface assumption layered on top of it — not a pure stylistic
preference the way the default ratio itself is, but not a figure with no
room for judgment either.
The sentence quoted above gives the fluent range’s other end too, and
docs/scale-floor.md is what this project decided to do with it. The short
version: run 4pt through the identical arithmetic and the bottom of the band
is 0.641026rem, about 10.26px. It is a real, measured number that the module
states and deliberately does not enforce, because an angular bound needs a
viewing distance a stylesheet cannot know, and because a clamp at the bottom is
the same design this document rejects at the top. What it does decide is how
deep the step table goes.
Worked examples
These are direct compilation results, not hand calculations.
The module’s own default is completely unaffected. At $scale-base: 1rem, $scale-ratio: 1.25, every role’s size matches the plain geometric
formula exactly — the largest role still comes out to 4.7684rem (76.29px),
identical to what it would be with no ceiling mechanism at all.
A project with an 18.5px body size, at a few different ratios:
$scale-ratio |
Second-largest role | Largest role | What’s happening |
|---|---|---|---|
1.25 (module default) |
70.57px | 88.21px | Right at this project’s own threshold — barely engages |
1.2143 |
59.31px | 72.02px | Reproduces a hand-picked 72px hero almost exactly, through plain geometric growth alone |
1.618 (golden ratio) |
101.77px | 102.12px | Heavily compressed, still clearly distinct from the role below it, nowhere near the uncapped 332px/537px |
2 (an octave) |
102.38px | 102.47px | Even more compressed; still distinct, still under the ceiling |
The middle row is the practical takeaway for anyone trying to hit a specific size: the ratio is the tool for that, not the ceiling. A project that wants a particular size for its largest role generally doesn’t need to think about the ceiling at all — as long as the target sits below it, picking the right ratio reaches it through the same plain formula this module has always used, with the curve staying completely out of the way.
What’s actually verified, not just claimed
- Exact identity at the default: zero measurable deviation from the plain geometric formula, confirmed by direct compilation, not asserted.
- Every role stays strictly larger than the one below it, for any
$scale-baseunder the guarded threshold (“The threshold has its own precondition”, above) — checked across a wide sweep of ratios (1.01 through 20.0) with no exceptions found under this design. (An earlier version of the curve — the one engineered to land exactly on the ceiling, discussed above — broke this as early as a ratio of about 5.5; this shape held throughout the entire range checked.) The sweep never varied$scale-basepast that threshold, and monotonicity is not what holds beyond it — the module refuses to compile there instead. - No kink in either value or slope at the threshold, confirmed by sampling the curve’s rate of change on both sides of it.
An edge case this module allows, on purpose
At a sufficiently extreme ratio, roles deep in the compressed region can become visually — and at truly extreme ratios, numerically — indistinguishable from each other. This is real: at an 18.5px body size, a ratio of 5 is already enough to make the two largest roles round to the same displayed value. This module does not refuse to compile a configuration like that. A project is entitled to choose a ratio that extreme if it genuinely wants to. What the module does instead is emit a build-time warning naming the roles that collided, so the outcome is visible rather than a silent surprise — the build still succeeds either way.
An open question, left for a future decision
The ceiling is not currently something a project can adjust. Every project gets the same fixed ~102.56px cap — including a project that might genuinely want larger display type than that (signage or kiosk-style hero text, for instance), or one that wants a tighter cap than the default without relying on choosing a narrower ratio to get there indirectly.
This is a deliberate, and deliberately narrow, scope decision rather than an oversight. The worked example above shows that for the common case, the ratio alone already reaches almost any practical target below the ceiling, which is why an adjustable ceiling wasn’t added as a new input here — it would add a new configuration surface without an established real need for it yet. If a genuine case turns up where the fixed ceiling doesn’t fit a project — too tight, or a legitimate need to exceed it — that’s the signal to revisit this as a real, documented configuration option, rather than something to guess at speculatively in advance.