Skip to content
@erikakers/typography

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:

plaintext
size(x) = x                                    for x <= T
size(x) = T + (x - T)(C - T) / (C + x - 2T)     for x > T

Below 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 display larger 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:

plaintext
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-base under 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-base past 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.