A Modular Type Scale with CSS Custom Properties: Ratio, calc() and Tokens

This guide belongs to the Type Scale & Modular Grids section, part of the Typography Fundamentals & System Architecture blueprint. It solves one precise problem: how to express a modular type scale entirely in CSS — no Sass, no build step — so every font-size in your system is derived from a single ratio and base, rather than hand-picked.

Problem Statement

Most stylesheets hard-code font sizes: h1 { font-size: 32px }, h2 { font-size: 24px }, and so on. The numbers drift over time, relationships between sizes are accidental, and changing the overall "feel" of the type means editing dozens of declarations. A modular scale fixes this by choosing a single ratio (a constant multiplier such as 1.25, the "major third") and a base size, then generating every step by repeated multiplication. The challenge is doing this in runtime CSScalc() cannot raise a number to a power directly, so you must chain multiplications or precompute each step as its own custom property. Done right, you get a token set (--step-0 through --step-5, plus negative steps) that any component can reference, and retuning the entire system is a one-line change to the ratio.

Prerequisites

  • A reset or base layer where you can declare :root custom properties. Everything here is plain CSS, so no tooling is required.
  • A decision on your ratio. Common musical ratios: 1.125 (major second, conservative), 1.2 (minor third), 1.25 (major third, a safe default), 1.333 (perfect fourth, dramatic), 1.618 (golden). A larger ratio means bigger jumps between heading levels.
  • A decision on your base size, almost always 1rem so the scale honours the user's browser font-size preference for accessibility.
A modular scale derived from a base and a 1.25 ratio Each step is the previous step multiplied by the ratio, producing tokens step-0 through step-5 with growing font sizes. --step-0 --step-1 --step-2 --step-3 --step-4 base x ratio^n ratio = 1.25
Each rectangle's height is its predecessor scaled by the 1.25 ratio — the visual shape of a modular scale.

Implementation

The cleanest approach precomputes each step as its own custom property by chaining calc() multiplications. This keeps the ratio defined in exactly one place while exposing flat tokens that components consume.

:root modular scale with derived step tokens and utility classes

:root {
  /* The two inputs that define the entire system. */
  --ratio: 1.25;          /* major third */
  --step-0: 1rem;         /* base body size; honours user prefs */

  /* Each step multiplies the previous by --ratio.
     calc() has no exponent operator, so we chain references. */
  --step-1: calc(var(--step-0) * var(--ratio));   /* 1.25rem  */
  --step-2: calc(var(--step-1) * var(--ratio));   /* 1.5625rem */
  --step-3: calc(var(--step-2) * var(--ratio));   /* 1.953rem  */
  --step-4: calc(var(--step-3) * var(--ratio));   /* 2.441rem  */
  --step-5: calc(var(--step-4) * var(--ratio));   /* 3.052rem  */

  /* Negative steps for captions / fine print, dividing by ratio. */
  --step--1: calc(var(--step-0) / var(--ratio));  /* 0.8rem  */
  --step--2: calc(var(--step--1) / var(--ratio)); /* 0.64rem */
}

/* Map the tokens onto semantic elements. */
body  { font-size: var(--step-0); line-height: 1.5; }
h6    { font-size: var(--step-1); }
h5    { font-size: var(--step-2); }
h4    { font-size: var(--step-3); }
h3    { font-size: var(--step-3); }
h2    { font-size: var(--step-4); }
h1    { font-size: var(--step-5); line-height: 1.1; }
small { font-size: var(--step--1); }

/* Optional utility classes for design-system usage. */
.text-xs  { font-size: var(--step--1); }
.text-sm  { font-size: var(--step-0);  }
.text-lg  { font-size: var(--step-2);  }
.text-xl  { font-size: var(--step-3);  }
.text-2xl { font-size: var(--step-4);  }

The annotated logic: --ratio and --step-0 are the only values a designer ever tunes. Every other size flows from them. Because calc() cannot compute pow(ratio, n), each step explicitly references the one below it — --step-2 multiplies --step-1, which multiplied --step-0. The custom property engine resolves this chain at use-time, so changing --ratio to 1.2 instantly recomputes the entire scale with no other edits. Negative steps (--step--1, --step--2) divide rather than multiply, giving you sub-base sizes for captions and metadata. Naming them with a double dash (--step--1) is legal CSS and reads as "step minus one." The utility classes and semantic element mappings are just consumers — they never contain raw numbers, which is the whole point: there is one source of truth for size relationships.

Defensive Variant: Fluid Steps with clamp()

A fixed scale is crisp but does not adapt the jump between sizes across viewports — a 3rem h1 that looks right on desktop can feel oversized on a phone. The defensive, responsive variant wraps each step in clamp() so it scales fluidly between a minimum and maximum, driven by the viewport. This is a natural progression toward a fully fluid type scale with clamp(), where the ratio itself can differ at the small and large ends.

Fluid modular steps using clamp() with viewport-relative growth

:root {
  --ratio: 1.25;
  --step-0: 1rem;

  /* Fixed lower bound, fluid middle, fixed upper bound.
     The vw term lets the size grow with the viewport; clamp()
     caps it so it never under- or over-shoots. */
  --step-3: clamp(
    calc(var(--step-0) * 1.6),   /* min: ~1.6rem on small screens */
    1.1rem + 2.5vw,              /* preferred: grows with viewport */
    calc(var(--step-0) * var(--ratio) * var(--ratio) * var(--ratio))
  );                              /* max: the fixed step-3 = ~1.95rem */

  --step-5: clamp(
    calc(var(--step-0) * 2),     /* min: 2rem */
    1.5rem + 4vw,                /* preferred */
    3.052rem                     /* max: the fixed step-5 */
  );
}

h1 { font-size: var(--step-5); line-height: 1.1; }
h2 { font-size: var(--step-3); }

Here each fluid step has three arguments: a minimum (the floor on narrow viewports), a preferred value mixing a rem constant with a vw term (the fluid growth), and a maximum (the ceiling, set to the original fixed step so large screens never exceed the designed scale). Keeping the maximum equal to the precomputed fixed step means the fluid version degrades gracefully into the static scale at the top end. If clamp() is unsupported (very old browsers), the property is simply invalid and the element falls back to its inherited or default size, so wrap critical fluid steps in @supports (font-size: clamp(1rem, 1vw, 2rem)) if you must support legacy engines.

Verification

  • Computed values. Inspect h1 in DevTools and read the Computed font-size. With --ratio: 1.25 and a 16px root, --step-5 should compute to roughly 48.8px (16 × 1.25⁵). Adjust --ratio to 1.2 and confirm the computed size drops to ~39.8px without any other edit.
  • Token sweep. Render one element per step (--step--2 through --step-5) in a column. The visual progression should feel even — each line a consistent proportional jump from the last. Uneven jumps mean a step was mis-chained.
  • Accessibility check. Increase the browser's default font size (or zoom). Because every step is rem-based and derived from --step-0: 1rem, the whole scale should grow proportionally. If any heading stays fixed, it has a stray px value bypassing the tokens.

Common Pitfalls

  • Defining sizes in px instead of rem. A px base breaks user font-size preferences and accessibility zoom; anchor --step-0 to 1rem.
  • Re-declaring raw sizes in components. The moment a component hard-codes font-size: 22px, it falls out of the scale and drifts. Always reference a --step-* token.
  • Forgetting calc() cannot do exponents. Writing calc(var(--step-0) * var(--ratio) * n) does not raise to a power; you must chain each step off the previous.
  • Pairing a large ratio with tight line-height. A 1.618 ratio produces dramatic headings that need looser leading; coordinate with your line-height and vertical rhythm so big steps do not collide.
  • Skipping negative steps. Without --step--1/--step--2, captions and fine print end up as arbitrary one-off sizes outside the system.

FAQ

Why use chained calc() instead of just writing the final rem values?

Chaining keeps the ratio as a single source of truth. With explicit calc() references, changing --ratio once recomputes every step automatically; with hard-coded rem values you would have to recalculate and re-edit each token by hand, which is exactly the drift the scale exists to prevent.

Can I generate the scale with a loop instead of listing each step?

Not in plain CSS today — there is no native loop or exponent operator, so the explicit per-step chain is the runtime-CSS idiom. A preprocessor (Sass @for with pow()) or a build script can emit the same --step-* properties if you prefer generating them, but the consuming CSS is identical.

Should every heading map to its own step?

Not necessarily. It is common to share a step between adjacent levels (e.g. h3 and h4 both on --step-3) so the scale has fewer, more distinct tiers. The token set gives you the freedom to assign steps semantically rather than one-per-heading.

Related