Mathematical Type Scales
A type scale is not a list of font sizes — it is a mathematical system. Every size in the scale has a precise, calculable relationship to every other size. This relationship creates visual harmony: headings that feel proportionally larger than body text, captions that feel proportionally smaller, all without arbitrary tuning.
The Problem with Arbitrary Sizes
Before modular scales became standard practice, font sizes were chosen by gut feeling:
/* Anti-pattern: arbitrary sizes */
.small { font-size: 11px; }
.body { font-size: 14px; }
.lead { font-size: 18px; }
.h3 { font-size: 22px; }
.h2 { font-size: 28px; }
.h1 { font-size: 36px; }The ratios here: 1.27, 1.22, 1.28, 1.27, 1.09, 1.29. They're similar but not identical. The visual result is type that's almost harmonious — close enough that users don't consciously notice the problem, but not close enough that it feels right.
A modular scale fixes this by using one ratio consistently.
Mathematical Foundation
The modular scale formula:
Where:
= size at step = base size (typically 1rem = 16px) = ratio = step (positive = larger, negative = smaller)
For a Perfect Fourth (
Each step is exactly 1.333× the previous — a mathematically consistent progression.
Choosing Your Ratio
The ratio encodes the visual density of your typography system. Smaller ratios = subtle hierarchy (good for data-heavy UIs). Larger ratios = dramatic hierarchy (good for marketing pages).
| Ratio | Interval Name | 3-Step Spread | Best For |
|---|---|---|---|
| 1.067 | Minor Second | 1rem → 1.21rem | Very dense UIs |
| 1.125 | Major Second | 1rem → 1.42rem | Dashboard UIs |
| 1.200 | Minor Third | 1rem → 1.73rem | Standard apps |
| 1.250 | Major Third | 1rem → 1.95rem | Balanced systems |
| 1.333 | Perfect Fourth | 1rem → 2.37rem | Strong hierarchy |
| 1.414 | Augmented Fourth | 1rem → 2.83rem | Bold editorial |
| 1.500 | Perfect Fifth | 1rem → 3.37rem | Display-focused |
| 1.618 | Golden Ratio | 1rem → 4.24rem | Max drama |
The Practical Sweet Spot
For most product UIs, 1.250 (Major Third) or 1.333 (Perfect Fourth) work best. Major Third gives you enough differentiation without the extreme size jumps that make large headings feel disconnected from body text.
The Two-Base Scale
For more control, use two bases at a fixed interval. Tim Brown's technique:
With base = 1rem, secondary base = 1.5rem, ratio = 1.618 (golden ratio):
Primary: 0.382, 0.618, 1, 1.618, 2.618, 4.236 Secondary: 0.573, 0.927, 1.5, 2.427, 3.927, 6.354
Interleaved: 0.382, 0.573, 0.618, 0.927, 1, 1.5, 1.618, 2.427, 2.618, ...
This produces a richer scale with more intermediate steps — useful when you need fine-grained size control between the rigid single-base steps.
Production Scale Generation
// scripts/generate-type-scale.ts
interface TypeScaleConfig {
base: number; // Base font size in px (e.g., 16)
ratio: number; // Modular scale ratio (e.g., 1.25)
steps: {
negative: number; // How many steps below base (e.g., 2 → xs, 2xs)
positive: number; // How many steps above base (e.g., 8 → lg through 8xl)
};
unit: 'rem' | 'em' | 'px';
precision: number; // Decimal places (e.g., 4)
}
interface ScaleStep {
step: number;
name: string;
px: number;
rem: number;
css: string;
}
const STEP_NAMES = [
'2xs', 'xs', 'sm', 'md', 'lg',
'xl', '2xl', '3xl', '4xl', '5xl',
'6xl', '7xl', '8xl', '9xl',
];
function generateTypeScale(config: TypeScaleConfig): ScaleStep[] {
const { base, ratio, steps, precision } = config;
const allSteps = steps.negative + 1 + steps.positive;
const scale: ScaleStep[] = [];
for (let i = -steps.negative; i <= steps.positive; i++) {
const px = base * Math.pow(ratio, i);
const rem = px / base;
const nameIndex = i + steps.negative;
const name = STEP_NAMES[nameIndex] ?? `step-${i}`;
scale.push({
step: i,
name,
px: parseFloat(px.toFixed(precision)),
rem: parseFloat(rem.toFixed(precision)),
css: `${parseFloat(rem.toFixed(precision))}rem`,
});
}
return scale;
}
function scaleToCSSCustomProperties(scale: ScaleStep[], prefix = 'font-size'): string {
const props = scale
.map(s => ` --${prefix}-${s.name}: ${s.css}; /* ${s.px.toFixed(1)}px */`)
.join('\n');
return `:root {\n${props}\n}`;
}
function scaleToJSONTokens(scale: ScaleStep[]): Record<string, unknown> {
return Object.fromEntries(
scale.map(s => [
s.name,
{
value: s.css,
attributes: {
px: s.px,
step: s.step,
},
},
])
);
}
// Usage
const scale = generateTypeScale({
base: 16,
ratio: 1.25,
steps: { negative: 2, positive: 8 },
unit: 'rem',
precision: 4,
});
console.log(scaleToCSSCustomProperties(scale));
/* Output:
:root {
--font-size-2xs: 0.64rem; /* 10.2px */
--font-size-xs: 0.8rem; /* 12.8px */
--font-size-sm: 0.875rem; /* 14px */
--font-size-md: 1rem; /* 16px */
--font-size-lg: 1.25rem; /* 20px */
--font-size-xl: 1.5625rem; /* 25px */
--font-size-2xl: 1.9531rem; /* 31.2px */
--font-size-3xl: 2.4414rem; /* 39px */
--font-size-4xl: 3.0518rem; /* 48.8px */
--font-size-5xl: 3.8147rem; /* 61px */
}
*/Fluid Type with clamp()
Static scales are a starting point — but a single size at all viewports is rarely ideal. clamp() makes sizes fluid:
font-size: clamp(minimum, preferred, maximum);The preferred value is a viewport-relative expression that interpolates linearly between minimum and maximum as the viewport width changes.
The clamp() Formula
To scale from minSize at minWidth to maxSize at maxWidth:
Example: scale h1 from 2rem at 320px to 4rem at 1440px.
So:
h1 {
font-size: clamp(2rem, 1.4286rem + 0.1786vw, 4rem);
}Wait — these numbers seem off. Let me recalculate with consistent units (convert to px first, then back):
In viewport units:
h1 {
font-size: clamp(2rem, 1.4286rem + 2.857vw, 4rem);
}Automated clamp() Generation
// utils/fluid-type.ts
interface FluidTypeConfig {
minSize: number; // rem
maxSize: number; // rem
minWidth: number; // px
maxWidth: number; // px
baseSize?: number; // default 16px
}
function fluidType(config: FluidTypeConfig): string {
const { minSize, maxSize, minWidth, maxWidth, baseSize = 16 } = config;
// Convert rem to px for calculation
const minPx = minSize * baseSize;
const maxPx = maxSize * baseSize;
// Calculate slope
const slope = (maxPx - minPx) / (maxWidth - minWidth);
const slopeVw = slope * 100;
// Calculate intercept
const interceptPx = minPx - slope * minWidth;
const interceptRem = interceptPx / baseSize;
// Round for CSS readability
const roundedSlope = parseFloat(slopeVw.toFixed(4));
const roundedIntercept = parseFloat(interceptRem.toFixed(4));
const preferred = interceptRem >= 0
? `${roundedIntercept}rem + ${roundedSlope}vw`
: `${roundedSlope}vw - ${Math.abs(roundedIntercept)}rem`;
return `clamp(${minSize}rem, ${preferred}, ${maxSize}rem)`;
}
// Generate a complete fluid scale
function generateFluidScale(
scale: ScaleStep[],
minWidth = 320,
maxWidth = 1440,
mobileShrink = 0.75 // mobile sizes are 75% of desktop
): Record<string, string> {
return Object.fromEntries(
scale.map(step => [
step.name,
fluidType({
minSize: step.rem * mobileShrink,
maxSize: step.rem,
minWidth,
maxWidth,
}),
])
);
}
// Usage
const majorThird = generateTypeScale({ base: 16, ratio: 1.25, steps: { negative: 2, positive: 8 }, unit: 'rem', precision: 4 });
const fluidScale = generateFluidScale(majorThird);
console.log(fluidScale['2xl']);
// "clamp(1.4648rem, 0.8594rem + 1.8945vw, 1.9531rem)"Aligning to Pixel Grid
Browser rendering is pixel-based. A computed font size of 20.3px renders differently than 20px. For body text especially, aligning to the pixel grid improves rendering quality.
The technique: snap the base to a nice pixel value, then accept that scale steps will be irrational.
For Inter at 16px base with 1.25 ratio:
- Step 0: 16.0px ✓ (exactly 1rem)
- Step 1: 20.0px ✓ (1.25rem = 20px exactly)
- Step 2: 25.0px ✓ (1.5625rem = 25px exactly)
- Step 3: 31.25px — rounds to 31px (acceptable)
- Step 4: 39.0625px — rounds to 39px (acceptable)
Major Third with 16px base is particularly clean because
Perfect Fourth caveat
Perfect Fourth (
Line Height Relationships
Line height is not an independent choice — it must be calibrated against font size. The optimal line-height for body text:
This empirical formula (approximated from accessibility research) gives:
| Font Size | Formula Result | Typical Recommendation |
|---|---|---|
| 12px | 1.64 | 1.5-1.6 |
| 14px | 1.63 | 1.5 |
| 16px | 1.625 | 1.5-1.625 |
| 18px | 1.62 | 1.5 |
| 24px | 1.60 | 1.4-1.5 |
| 32px | 1.59 | 1.3-1.4 |
| 48px | 1.57 | 1.1-1.3 |
| 64px | 1.56 | 1.0-1.15 |
In practice: body text 1.5-1.6, headings 1.1-1.3, display text 1.0-1.1.
:root {
/* Line height scale — unitless values */
--lh-none: 1; /* Display, oversized headings */
--lh-tighter: 1.1; /* Large headings (h1, display) */
--lh-tight: 1.25; /* Medium headings (h2, h3) */
--lh-snug: 1.375; /* Small headings (h4, h5) */
--lh-normal: 1.5; /* Body text (standard) */
--lh-relaxed: 1.625; /* Body text (spacious) */
--lh-loose: 2; /* Caption, fine print */
}Measure (Character Width)
"Measure" is typography's term for line length. Optimal reading measure:
For body text at 1rem (16px) with Inter, a character is approximately 8-9px wide. For 65-character measure:
/* Constrain measure using ch units */
.prose {
max-width: 65ch; /* ~65 characters regardless of font size */
}
/* Or use a fixed pixel width calibrated to your font */
.prose {
max-width: 720px; /* ~90ch at 16px Inter — wider, comfortable for modern screens */
}ch unit note
The ch unit equals the width of the "0" glyph in the current font. For proportional fonts, average character width is approximately 0.45-0.55× the width of "0" — so 60ch corresponds to roughly 27-33 average characters. Calibrate with your specific font.
Implementing the Scale in a Design System
Style Dictionary Integration
// tokens/font-size.json
{
"fontSize": {
"2xs": {
"value": "0.64rem",
"$type": "dimension",
"description": "Step -2 of Major Third scale. Use for badges, legal text."
},
"xs": {
"value": "0.8rem",
"$type": "dimension",
"description": "Step -1. Use for timestamps, metadata."
},
"sm": {
"value": "0.875rem",
"$type": "dimension",
"description": "Step -0.5 (hand-tuned for 14px). Common UI body text."
},
"md": {
"value": "1rem",
"$type": "dimension",
"description": "Base step 0. 16px. Default body text."
},
"lg": {
"value": "1.25rem",
"$type": "dimension",
"description": "Step 1. Lead text, emphasized body."
},
"xl": {
"value": "1.5625rem",
"$type": "dimension",
"description": "Step 2. Subheadings (h4, h3)."
},
"2xl": {
"value": "1.9531rem",
"$type": "dimension",
"description": "Step 3. Section headings (h2)."
},
"3xl": {
"value": "2.4414rem",
"$type": "dimension",
"description": "Step 4. Page headings (h1)."
},
"4xl": {
"value": "3.0518rem",
"$type": "dimension",
"description": "Step 5. Hero headings."
},
"5xl": {
"value": "3.8147rem",
"$type": "dimension",
"description": "Step 6. Display text."
}
}
}Tailwind Configuration
// tailwind.config.ts
import type { Config } from 'tailwindcss';
const config: Config = {
theme: {
extend: {
fontSize: {
'2xs': ['0.64rem', { lineHeight: '1rem' }],
xs: ['0.8rem', { lineHeight: '1rem' }],
sm: ['0.875rem',{ lineHeight: '1.25rem' }],
base: ['1rem', { lineHeight: '1.5rem' }],
lg: ['1.25rem', { lineHeight: '1.75rem' }],
xl: ['1.5625rem', { lineHeight: '2rem' }],
'2xl': ['1.953rem', { lineHeight: '2.25rem' }],
'3xl': ['2.441rem', { lineHeight: '2.5rem' }],
'4xl': ['3.052rem', { lineHeight: '1', letterSpacing: '-0.02em' }],
'5xl': ['3.815rem', { lineHeight: '1', letterSpacing: '-0.03em' }],
// Fluid variants
'fluid-lg': ['clamp(1.25rem, 2vw, 1.953rem)', { lineHeight: '1.4' }],
'fluid-xl': ['clamp(1.953rem, 4vw, 3.052rem)', { lineHeight: '1.2' }],
'fluid-2xl': ['clamp(2.441rem, 6vw, 3.815rem)', { lineHeight: '1.1' }],
},
},
},
};
export default config;Edge Cases and Failure Modes
The Scaling Trap at Large Sizes
Large font sizes need negative letter spacing to look optically correct. As font size increases, the default letter spacing (designed for body text) makes letters feel too far apart:
/* Correct letter spacing per size */
.text-sm { letter-spacing: 0; }
.text-md { letter-spacing: 0; }
.text-lg { letter-spacing: -0.01em; }
.text-xl { letter-spacing: -0.02em; }
.text-2xl { letter-spacing: -0.025em; }
.text-3xl { letter-spacing: -0.03em; }
.text-4xl { letter-spacing: -0.04em; }
.text-5xl { letter-spacing: -0.05em; }Scale Collapse at Small Viewports
A Major Third scale is lovely on desktop but produces awkward sizes at mobile widths. Step 4 (48.8px) at 320px viewport is 15% of screen width per character — essentially unusable without reducing the scale.
Solutions:
- Two separate scales (mobile 1.2 ratio, desktop 1.333 ratio, blend via
clamp) - Fluid scale where each step interpolates independently
- Cap at viewport width:
min(3rem, 8vw)
User Font Size Preferences
Users can change their browser's default font size (accessibility). If you use px for font sizes, you override this preference — a WCAG 1.4.4 failure.
Always use rem for font sizes. The rem unit is relative to the root font-size, which browsers set based on user preference (default 16px, but may be 18px, 20px, or 24px for users with vision impairments).
/* Never */
.body-text { font-size: 16px; }
/* Always */
.body-text { font-size: 1rem; }Performance Characteristics
Font size declarations have zero runtime cost — they're layout-time properties. The main performance concern is:
- Repaints from font-size animation: animating
font-sizetriggers layout (expensive). Usetransform: scale()for animation instead. - CSS custom property resolution: Each
var()call adds ~0.1ms per element. At hundreds of elements this is measurable but acceptable. - CSSOM size: A 500-line font token file parses in <1ms on modern browsers.
War Story
A fintech dashboard team generated their type scale programmatically in JavaScript at runtime — reading the scale config JSON, computing all sizes, and injecting a <style> tag on every render. The type scale re-injected on every React re-render of the root component, which happened 40+ times per second during data updates. The fix was CSS custom properties in a static stylesheet: zero JavaScript, zero re-injection, zero runtime cost.
Complete Production Token File
/* ============================================
TYPE SCALE — Major Third (1.25)
Base: 16px / 1rem
Generated: 2026-03-18
============================================ */
:root {
/* ---- Primitive Size Scale ---- */
--font-size-2xs: 0.64rem; /* 10.24px */
--font-size-xs: 0.8rem; /* 12.80px */
--font-size-sm: 0.875rem; /* 14.00px */
--font-size-md: 1rem; /* 16.00px */
--font-size-lg: 1.25rem; /* 20.00px */
--font-size-xl: 1.5625rem; /* 25.00px */
--font-size-2xl: 1.9531rem; /* 31.25px */
--font-size-3xl: 2.4414rem; /* 39.06px */
--font-size-4xl: 3.0518rem; /* 48.83px */
--font-size-5xl: 3.8147rem; /* 61.04px */
--font-size-6xl: 4.7684rem; /* 76.29px */
/* ---- Fluid Scale (320px → 1440px) ---- */
--font-size-fluid-sm: clamp(0.875rem, 0.8rem + 0.234vw, 1rem);
--font-size-fluid-md: clamp(1rem, 0.893rem + 0.335vw, 1.25rem);
--font-size-fluid-lg: clamp(1.25rem, 0.973rem + 0.865vw, 1.953rem);
--font-size-fluid-xl: clamp(1.953rem, 1.384rem + 1.779vw, 3.052rem);
--font-size-fluid-2xl: clamp(2.441rem, 1.619rem + 2.569vw, 3.815rem);
--font-size-fluid-3xl: clamp(3.052rem, 1.835rem + 3.804vw, 4.768rem);
/* ---- Semantic Sizes ---- */
--text-caption: var(--font-size-xs);
--text-body-sm: var(--font-size-sm);
--text-body: var(--font-size-fluid-sm);
--text-body-lg: var(--font-size-fluid-md);
--text-h5: var(--font-size-xl);
--text-h4: var(--font-size-xl);
--text-h3: var(--font-size-fluid-lg);
--text-h2: var(--font-size-fluid-xl);
--text-h1: var(--font-size-fluid-2xl);
--text-display: var(--font-size-fluid-3xl);
/* ---- Line Heights ---- */
--lh-none: 1;
--lh-tighter: 1.1;
--lh-tight: 1.25;
--lh-snug: 1.375;
--lh-normal: 1.5;
--lh-relaxed: 1.625;
--lh-loose: 2;
/* ---- Semantic Line Heights ---- */
--lh-caption: var(--lh-normal);
--lh-body: var(--lh-normal);
--lh-body-lg: var(--lh-relaxed);
--lh-heading: var(--lh-tight);
--lh-display: var(--lh-tighter);
/* ---- Letter Spacing ---- */
--ls-tighter: -0.05em;
--ls-tight: -0.025em;
--ls-normal: 0;
--ls-wide: 0.025em;
--ls-wider: 0.05em;
--ls-widest: 0.1em;
}