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 CSS — calc() 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
:rootcustom 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 — see Choosing a Modular Scale Ratio for UI Type for how to pick one against real content density.
- A decision on your base size, almost always
1remso the scale honours the user's browser font-size preference for accessibility.
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.
Comparing Ratios in Practice
The chained-calc() mechanism is identical whatever ratio you choose, but the resulting sizes diverge quickly once you reach --step-3 and above. Swapping only the --ratio value in the :root block above produces these outputs at a 16px root:
| Ratio value | step-3 size | step-5 size |
|---|---|---|
| 1.2 (minor third) | 1.728rem (~27.6px) | 2.488rem (~39.8px) |
| 1.25 (major third) | 1.953rem (~31.2px) | 3.052rem (~48.8px) |
| 1.333 (perfect fourth) | 2.370rem (~37.9px) | 4.214rem (~67.4px) |
| 1.618 (golden) | 4.236rem (~67.8px) | 11.09rem (~177.4px) |
A conservative ratio (1.125–1.2) suits data-dense interfaces — dashboards, admin panels, tables — where headings need to stay compact relative to body copy. A dramatic ratio (1.333–1.618) suits editorial or marketing pages where a handful of large display headings justify the visual jump. Mixing ratios per section is possible by scoping a second --ratio override on a container class, but most systems are better served by one ratio site-wide plus selective heading overrides, because two competing ratios make the relationship between any two elements unpredictable to a reader's eye even if it is mathematically well-defined.
Naming Conventions and Token Design
Numeric step names (--step-0, --step-1, …) map cleanly onto the mathematics of the scale, but some teams prefer semantic or t-shirt-size aliases layered on top so component CSS reads intent rather than position:
:root {
/* ...ratio and --step-* tokens as above... */
/* Semantic aliases: components reference these, never --step-* directly. */
--font-size-caption: var(--step--1);
--font-size-body: var(--step-0);
--font-size-subheading: var(--step-2);
--font-size-heading: var(--step-4);
--font-size-display: var(--step-5);
}
This indirection buys you one more degree of freedom: if a future redesign decides captions should sit at --step--2 instead of --step--1, you edit one alias instead of hunting every component that referenced --step--1 directly. The cost is an extra layer of indirection to trace when debugging computed values in DevTools — for a small system of five to seven steps this is rarely worth it, but for a shared component library consumed by multiple teams, semantic aliases prevent consumers from coupling to the raw mathematical position of a step.
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.
Edge Case: Container-Relative Steps
Viewport units (vw) tie a step's fluid growth to the whole browser window, which is wrong for a component that only ever renders inside a narrower card or sidebar — a 2.5vw term computes against the full viewport even when the component occupies a quarter of it. If your layout already uses CSS container queries, swap the vw unit for cqw (container query width) so the step responds to its containing element instead of the viewport:
.card {
container-type: inline-size;
container-name: card;
}
.card h3 {
/* Grows with the card's own width, not the viewport's. */
font-size: clamp(1.1rem, 1rem + 2cqw, 1.6rem);
}
This matters in dashboards and multi-column layouts where the same heading component is dropped into both a full-width hero and a narrow sidebar card: with vw both instances grow identically regardless of available space; with cqw each instance's type scales to what it actually has room for. Browser support for container query units trails plain clamp() by roughly two years, so treat this as a progressive enhancement layered on top of the vw-based fluid steps, not a replacement for them.
Theming: Swapping the Scale Per Brand or Mode
Because the entire scale hangs off two custom properties, a second brand, a print stylesheet, or a compact "dense mode" toggle can override just --ratio and --step-0 on a scoping class without touching a single component:
/* Default marketing site: a dramatic ratio for big display headings. */
:root { --ratio: 1.333; --step-0: 1rem; }
/* Admin dashboard embedded in the same codebase: tighter, calmer scale. */
.theme-dashboard { --ratio: 1.125; --step-0: 0.9375rem; }
/* Print stylesheet: slightly larger base, conservative ratio for pagination. */
@media print {
:root { --ratio: 1.2; --step-0: 12pt; }
}
Because every h1–h6, utility class, and semantic alias resolves through var(--step-N), none of that CSS needs to change when a .theme-dashboard wrapper is applied — the cascade recomputes every dependent token automatically. This is the single biggest practical payoff of doing the scale in custom properties rather than baking final rem values into a Sass map at build time: build-time Sass requires recompiling a second stylesheet per theme, while the custom-property version recomputes live in the browser from one shipped CSS file.
Generating Steps Programmatically
Five or six steps is easy to hand-write, but a system with ten or more steps (common in large design systems that need very fine caption and display gradations) benefits from generating the chain rather than typing it. A short Node script keeps the generated CSS in sync with a single ratio constant and avoids transcription errors in the exponents:
// generate-scale.js — emits chained calc() custom properties
const RATIO = 1.25;
const STEPS_UP = 6; // --step-0 .. --step-5
const STEPS_DOWN = 2; // --step--1, --step--2
const lines = [':root {', ' --ratio: ' + RATIO + ';', ' --step-0: 1rem;'];
for (let i = 1; i <= STEPS_UP; i++) {
lines.push(` --step-${i}: calc(var(--step-${i - 1}) * var(--ratio));`);
}
for (let i = 1; i <= STEPS_DOWN; i++) {
lines.push(` --step--${i}: calc(var(--step--${i - 1 || 0}) / var(--ratio));`);
}
lines.push('}');
console.log(lines.join('\n'));
The output is identical, hand-checkable CSS — this script is a convenience for authoring, not a runtime dependency; nothing it produces requires JavaScript in the browser. Running it whenever RATIO or STEPS_UP changes eliminates the class of bug where a manually chained step accidentally references the wrong predecessor (for example --step-4 multiplying --step-2 instead of --step-3, silently skipping a multiplication).
Verification
- Computed values. Inspect
h1in DevTools and read the Computedfont-size. With--ratio: 1.25and a 16px root,--step-5should compute to roughly 48.8px (16 × 1.25⁵). Adjust--ratioto1.2and confirm the computed size drops to ~39.8px without any other edit. - Token sweep. Render one element per step (
--step--2through--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 straypxvalue bypassing the tokens. - Programmatic check with getComputedStyle. In the console, read a token directly and confirm the cascade resolved it:
getComputedStyle(document.documentElement).getPropertyValue('--step-5')should return the rawcalc()expression string (custom properties resolve to their computed pixel value only on the property that consumes them, such asfont-size, not on the custom property itself) — to see the resolved pixel number, instead readgetComputedStyle(document.querySelector('h1')).fontSize.
Common Pitfalls
- Defining sizes in
pxinstead ofrem. Apxbase breaks user font-size preferences and accessibility zoom; anchor--step-0to1rem. - 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. Writingcalc(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. - Using
vwwherecqwis needed. A component that must scale relative to its own container, not the viewport, will look wrong when dropped into a narrower parent if the fluid step is written withvw; see the container-relative edge case above. - Letting a theme override drift out of sync. A
.theme-dashboardoverride that redefines--step-3directly (instead of--ratioand--step-0) breaks the chain for that one step and reintroduces the exact drift the token system exists to prevent — always override the two inputs, never a derived step.
Frequently Asked Questions
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 small Node script like the one above can emit the same --step-* properties if you prefer generating them, but the consuming CSS is identical either way.
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.
Does changing --ratio on a scoped class actually recompute deeper chained steps, or only the first one?
The whole chain recomputes. Because --step-2 is defined as calc(var(--step-1) * var(--ratio)) and --step-1 itself references --ratio, the cascade resolves custom properties lazily at the point of use — overriding --ratio inside .theme-dashboard changes what every dependent var(--ratio) reference resolves to for elements inside that scope, all the way up the chain to --step-5.
Do I need a negative step below --step--2?
Rarely. Two negative steps (roughly 0.8rem and 0.64rem at a 1.25 ratio) comfortably cover captions and legal fine print; a third negative step usually falls below comfortable reading size and signals the content should not be rendered as body text at all, but as a label or icon annotation instead.
Related
- Type Scale & Modular Grids — the parent section on building proportional type systems.
- Choosing a Modular Scale Ratio for UI Type — how to pick a ratio for your content density.
- Implementing a Fluid Type Scale with clamp() — extend these tokens into a fully responsive scale.
- Line-Height & Vertical Rhythm — pairing scale steps with consistent leading.