Building a Modular Type Scale with Custom Properties

Part of Typography Scale Systems. This page walks through generating a complete modular type scale — from a single base size and a ratio — and emitting each step as a --font-size-* CSS custom property, with companion --line-height-* and --letter-spacing-* tokens that stay consistent across every size.

Modular type scale stepped bar diagram Seven horizontal bars of increasing height representing font size steps from xs through 3xl, each labelled with its token name and a relative size value derived from base × ratio. Modular Scale — base 16px × ratio 1.25 (Major Third) font-size (px) ~10px --font-size-xs ~13px --font-size-sm 16px --font-size-base 20px --font-size-md 25px --font-size-lg 31px --font-size-xl 39px --font-size-2xl Each step × ratio 1.25
Seven steps of a Major Third modular scale (ratio 1.25) anchored to a 16 px base, each emitted as a --font-size-* token.

Prerequisites

Before you start, confirm that all of the following are in place:

  • Token file convention — Your project stores primitive tokens in one or more JSON or CSS files (e.g., tokens/typography.css or tokens/primitives.json). The new --font-size-* tokens belong at the primitive tier.
  • Root font-size agreement — The browser root is 16 px (the default). All rem calculations on this page assume that. If your project overrides html { font-size }, adjust the denominator accordingly.
  • CSS custom property support — All target browsers support custom properties (everything post-IE 11). No polyfill is required for modern stacks.
  • Build toolchain (optional but recommended) — If you want pow() support without waiting for broad browser adoption, you need either PostCSS with postcss-custom-properties + a generator script, or a Style Dictionary transform. Pure-CSS pow() via exp() and log() landed in Chrome 120 / Firefox 118 / Safari 15.4, so it is usable in greenfield projects today.
  • Existing scale audit complete — Run a grep over your codebase to enumerate every hardcoded font-size value before you start: grep -rE 'font-size\s*:\s*[0-9]+(px|rem)' src/. You will need this list for the migration step.

Step 1 — Define the Scale Parameters as Tokens

Pick a ratio. The two most common for UI work are Major Third (1.25) and Perfect Fourth (1.333). Major Third produces a gentler ramp suitable for dense data UIs; Perfect Fourth creates clear hierarchy faster, ideal for marketing or editorial layouts.

/* tokens/typography.css */
:root {
  /* Scale primitives — do not consume these directly in components */
  --scale-base-px: 16;            /* unitless, used in calc() */
  --scale-ratio: 1.25;            /* Major Third */
  --scale-root-px: 16;            /* assumed browser root — adjust if overridden */
}

Why this works: Keeping --scale-base-px and --scale-ratio as unitless numbers lets you multiply them in calc() without triggering CSS unit-type errors. A comment in the file makes the intent explicit to anyone who picks up this codebase.

Step 2 — Emit Each Step Using calc() and pow()

Modern CSS has pow() inside calc() since late 2023. The pattern calc(pow(var(--scale-ratio), N) * 1rem) generates step N above the base, where N can be negative for sub-base sizes.

/* tokens/typography.css — continued */
:root {
  /* Steps below base */
  --font-size-xs:   calc(pow(var(--scale-ratio), -2) * 1rem);  /* ≈ 0.64rem / 10.24px */
  --font-size-sm:   calc(pow(var(--scale-ratio), -1) * 1rem);  /* ≈ 0.80rem / 12.80px */

  /* Base step */
  --font-size-base: 1rem;                                       /* 16px */

  /* Steps above base */
  --font-size-md:   calc(pow(var(--scale-ratio), 1) * 1rem);   /* ≈ 1.25rem / 20px */
  --font-size-lg:   calc(pow(var(--scale-ratio), 2) * 1rem);   /* ≈ 1.563rem / 25px */
  --font-size-xl:   calc(pow(var(--scale-ratio), 3) * 1rem);   /* ≈ 1.953rem / 31.25px */
  --font-size-2xl:  calc(pow(var(--scale-ratio), 4) * 1rem);   /* ≈ 2.441rem / 39.06px */
  --font-size-3xl:  calc(pow(var(--scale-ratio), 5) * 1rem);   /* ≈ 3.052rem / 48.83px */
}

Why this works: Because --scale-ratio is live, swapping a single variable from 1.25 to 1.333 recomputes all eight steps simultaneously. You never touch the component layer to rebrand the scale.

Build-time fallback (for browsers before pow())

If you need to support Safari < 15.4 or Firefox < 118, pre-compute the values at build time and emit literal rem values. This Node script reads the ratio and writes a CSS file:

// scripts/generate-type-scale.js
const ratio = 1.25;
const base  = 16;       // px
const steps = [
  { name: 'xs',   exp: -2 },
  { name: 'sm',   exp: -1 },
  { name: 'base', exp:  0 },
  { name: 'md',   exp:  1 },
  { name: 'lg',   exp:  2 },
  { name: 'xl',   exp:  3 },
  { name: '2xl',  exp:  4 },
  { name: '3xl',  exp:  5 },
];

const lines = steps.map(({ name, exp }) => {
  const px  = base * Math.pow(ratio, exp);
  const rem = (px / base).toFixed(4);
  return `  --font-size-${name}: ${rem}rem; /* ${px.toFixed(2)}px */`;
});

const css = `:root {\n${lines.join('\n')}\n}\n`;
require('fs').writeFileSync('tokens/generated/type-scale.css', css);
console.log('Type scale written.');

Run with node scripts/generate-type-scale.js as part of your design-token build step. The output is deterministic and diffable in pull requests.

Why this works: Hardcoded rem values land in the browser with zero runtime computation and have perfect cross-browser support. Combine with the live pow() approach by conditionally @import-ing the generated file only when pow() is unsupported, or just use the generated file everywhere until pow() adoption is universal.

Step 3 — Add Line-Height Tokens for Each Step

A modular scale applied only to font sizes is incomplete. Line heights must track the type size to maintain vertical rhythm. A common heuristic: line-height starts tight at large sizes (1.1–1.2) and opens up at body sizes (1.5–1.6).

/* tokens/typography.css — continued */
:root {
  --line-height-xs:   1.6;   /* small text needs breathing room */
  --line-height-sm:   1.55;
  --line-height-base: 1.5;
  --line-height-md:   1.4;
  --line-height-lg:   1.3;
  --line-height-xl:   1.2;
  --line-height-2xl:  1.15;
  --line-height-3xl:  1.1;   /* large display type sits tighter */
}

Why this works: Line-height tokens are intentionally unitless ratios rather than px or rem values. Unitless line-height inherits correctly across nested elements and avoids the common pitfall of fixed-pixel line heights compressing or overflowing when the user zooms.

Step 4 — Add Letter-Spacing (Tracking) Tokens

Tracking also scales with type: small text benefits from loose tracking for legibility; large display type should be tightened to avoid gaps between glyphs.

/* tokens/typography.css — continued */
:root {
  --letter-spacing-xs:   0.05em;   /* open tracking for small labels */
  --letter-spacing-sm:   0.03em;
  --letter-spacing-base: 0em;      /* no adjustment at body size */
  --letter-spacing-md:   0em;
  --letter-spacing-lg:  -0.01em;
  --letter-spacing-xl:  -0.02em;
  --letter-spacing-2xl: -0.03em;
  --letter-spacing-3xl: -0.04em;  /* tight tracking for display headings */
}

Why this works: em-based tracking scales proportionally with the font size it decorates. If you later adjust --font-size-xl, the tracking stays correct without a separate update.

Step 5 — Group All Three Tokens into a Semantic Set

Semantic tokens map a UI role (e.g., --text-heading-lg) to the three companion primitives. This is the layer that component CSS actually consumes — never the primitive --font-size-* tokens directly.

/* tokens/semantic/typography.css */
:root {
  /* Body text */
  --text-body:            var(--font-size-base);
  --text-body-lh:         var(--line-height-base);
  --text-body-tracking:   var(--letter-spacing-base);

  /* UI labels / captions */
  --text-label:           var(--font-size-sm);
  --text-label-lh:        var(--line-height-sm);
  --text-label-tracking:  var(--letter-spacing-sm);

  /* Section headings */
  --text-heading-sm:      var(--font-size-md);
  --text-heading-sm-lh:   var(--line-height-md);
  --text-heading-sm-tk:   var(--letter-spacing-md);

  --text-heading-md:      var(--font-size-lg);
  --text-heading-md-lh:   var(--line-height-lg);
  --text-heading-md-tk:   var(--letter-spacing-lg);

  --text-heading-lg:      var(--font-size-xl);
  --text-heading-lg-lh:   var(--line-height-xl);
  --text-heading-lg-tk:   var(--letter-spacing-xl);

  /* Display / hero headings */
  --text-display:         var(--font-size-2xl);
  --text-display-lh:      var(--line-height-2xl);
  --text-display-tk:      var(--letter-spacing-2xl);
}

Why this works: When your designer decides the hero heading should move from 2xl to 3xl, you update one var() reference in the semantic layer — no component CSS changes required. This is the same indirection principle used in the Houdini @property type-safe token approach, where the primitive definition and the semantic consumption are kept strictly separate.

Step 6 — Apply Semantic Tokens in Component CSS

Components consume only semantic tokens. Never reach past the semantic layer to the raw --font-size-* values.

/* components/article.css */
.article__heading {
  font-size:      var(--text-heading-lg);
  line-height:    var(--text-heading-lg-lh);
  letter-spacing: var(--text-heading-lg-tk);
}

.article__body {
  font-size:      var(--text-body);
  line-height:    var(--text-body-lh);
  letter-spacing: var(--text-body-tracking);
}

.article__caption {
  font-size:      var(--text-label);
  line-height:    var(--text-label-lh);
  letter-spacing: var(--text-label-tracking);
}

Why this works: Consuming only semantic tokens means your components are immune to scale refactors. If you later migrate the scale to fluid values using CSS clamp() functions mapped from typography tokens, the component files need zero changes — the update happens entirely at the token definition layer.

Step 7 — Register Tokens with @property for Type Safety (Optional)

If your target browsers support Houdini registered properties, lock the type of each font-size token to prevent accidental unitless or color assignments from silently corrupting the scale.

/* tokens/registered.css */
@property --font-size-base {
  syntax: '<length>';
  inherits: true;
  initial-value: 1rem;
}

@property --font-size-xl {
  syntax: '<length>';
  inherits: true;
  initial-value: 1.953rem;
}

@property --line-height-base {
  syntax: '<number>';
  inherits: true;
  initial-value: 1.5;
}

@property --letter-spacing-base {
  syntax: '<length>';
  inherits: true;
  initial-value: 0em;
}

Why this works: A registered <length> token throws a computed-value error rather than silently falling back when, for example, a typo sets --font-size-xl: bold. The browser ignores the invalid assignment and keeps the initial-value, keeping your layout predictable. See the @property syntax reference in the Typography Scale Systems overview for the full registration pattern.

Verification

Confirm the scale is working correctly using three checks.

1. DevTools computed-value check. Open Chrome DevTools, select the <html> element, and inspect the Computed tab. Every --font-size-* variable should resolve to a pixel value consistent with base × ratio^N. For a 1.25 ratio, --font-size-lg should read approximately 25px (16 × 1.25²).

2. Ratio consistency test. Write a JavaScript snippet in the DevTools console to verify each adjacent step maintains the ratio:

const root  = document.documentElement;
const style = getComputedStyle(root);
const steps = ['xs','sm','base','md','lg','xl','2xl','3xl'];
const px    = steps.map(s =>
  parseFloat(style.getPropertyValue(`--font-size-${s}`))
);
const ratios = px.slice(1).map((v, i) => (v / px[i]).toFixed(4));
console.table(ratios); // All values should equal ~1.25

If any ratio deviates by more than 0.01, a rounding error or missing step has been introduced.

3. Vertical rhythm audit. Use the browser’s accessibility panel or a line-height overlay bookmarklet to confirm that paragraph text sits on a consistent baseline grid. The product of --font-size-base and --line-height-base (e.g., 16px × 1.5 = 24px) should define your grid unit. Heading line-heights should be whole multiples or common fractions of that unit.

Troubleshooting

Symptom Likely Cause Fix
All --font-size-* tokens resolve to 0px or 1rem pow() not supported in target browser; tokens silently fall back Use the build-time Node generator (Step 2) to emit literal rem values instead of runtime pow() calls
Adjacent scale steps look visually identical Ratio is too small (e.g., 1.1); 4px increments at body size are imperceptible Increase ratio to 1.25 or 1.333, or consider a double-stranded scale with two interleaved ratios
Sub-pixel sizes cause blurry text on 1× displays --font-size-xs computes to a fractional px value like 10.24px Add a PostCSS step to round sub-base steps to the nearest 0.5px, or avoid exposing steps more than two below base in component CSS
Line-height tokens produce inconsistent vertical rhythm --line-height-* values were set in px rather than unitless ratios Replace all fixed px line-heights with unitless numbers; audit with grep -rE 'line-height\s*:\s*[0-9]+px'
rem values drift after changing html { font-size } --scale-root-px comment was updated but hard-coded rem fallbacks were not regenerated Re-run the generator script whenever root font-size changes; add a CI assertion that compares the declared root size to the generator constant

Migration Note

If your codebase currently has ad-hoc font-size declarations scattered across component files, migrate in three phases to avoid regressions.

Phase 1 — Audit and map. Run the grep from the Prerequisites section and produce a spreadsheet mapping each unique hardcoded size to the nearest modular scale step. Sizes that fall between steps should be rounded to the closest value rather than extended the scale indefinitely.

Phase 2 — Introduce tokens, keep fallbacks. Add the tokens/typography.css file to your entry point. Replace each hardcoded value with the semantic token, keeping the raw pixel value as a CSS comment: font-size: var(--text-body); /* was: 16px */. This makes PR review straightforward and gives you a quick rollback path.

Phase 3 — Remove legacy values. After two release cycles without regression reports, strip the comments and run Stylelint with a custom rule (no-hardcoded-font-sizes) that flags any remaining literal px or rem font sizes outside the token files. Add this check to CI so the problem cannot reappear.