Skip to content
Unverified — AI-generated content. Help verify this page

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:

css
/* 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:

sn=s0×rn

Where:

  • sn = size at step n
  • s0 = base size (typically 1rem = 16px)
  • r = ratio
  • n = step (positive = larger, negative = smaller)

For a Perfect Fourth (r=1.333) with s0=1rem:

s2=1×1.3332=0.5625rems1=1×1.3331=0.75rems0=1×1.3330=1rems1=1×1.3331=1.333rems2=1×1.3332=1.777rems3=1×1.3333=2.369rems4=1×1.3334=3.157rems5=1×1.3335=4.209rem

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).

RatioInterval Name3-Step SpreadBest For
1.067Minor Second1rem → 1.21remVery dense UIs
1.125Major Second1rem → 1.42remDashboard UIs
1.200Minor Third1rem → 1.73remStandard apps
1.250Major Third1rem → 1.95remBalanced systems
1.333Perfect Fourth1rem → 2.37remStrong hierarchy
1.414Augmented Fourth1rem → 2.83remBold editorial
1.500Perfect Fifth1rem → 3.37remDisplay-focused
1.618Golden Ratio1rem → 4.24remMax 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:

sn=s0×rnandsn=s1×rn

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

typescript
// 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:

css
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:

slope=maxSizeminSizemaxWidthminWidthintercept=minSizeslope×minWidthpreferred=intercept+slope×100vw

Example: scale h1 from 2rem at 320px to 4rem at 1440px.

slope=421440320=21120=0.001786intercept=20.001786×320=20.5714=1.4286rempreferred=1.4286rem+0.1786vw

So:

css
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):

slope=64321440320=321120=0.02857

In viewport units: 0.02857×100=2.857vw

intercept=320.02857×320=329.14=22.86px=1.4286rem

css
h1 {
  font-size: clamp(2rem, 1.4286rem + 2.857vw, 4rem);
}

Automated clamp() Generation

typescript
// 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 16×1.25=20 and 20×1.25=25 — both nice integers.

Perfect Fourth caveat

Perfect Fourth (1.3333) with 16px base produces 21.33px at step 1 — not pixel-aligned. Consider 1.3333 as your ratio and accept sub-pixel rendering, or use 15px as your base (15×1.33320).

Line Height Relationships

Line height is not an independent choice — it must be calibrated against font size. The optimal line-height for body text:

line-height=1+0.5font-size in rem

This empirical formula (approximated from accessibility research) gives:

Font SizeFormula ResultTypical Recommendation
12px1.641.5-1.6
14px1.631.5
16px1.6251.5-1.625
18px1.621.5
24px1.601.4-1.5
32px1.591.3-1.4
48px1.571.1-1.3
64px1.561.0-1.15

In practice: body text 1.5-1.6, headings 1.1-1.3, display text 1.0-1.1.

css
: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:

45characters per line75

For body text at 1rem (16px) with Inter, a character is approximately 8-9px wide. For 65-character measure: 65×8.5552px

css
/* 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

json
// 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

typescript
// 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:

css
/* 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:

  1. Two separate scales (mobile 1.2 ratio, desktop 1.333 ratio, blend via clamp)
  2. Fluid scale where each step interpolates independently
  3. 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).

css
/* 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:

  1. Repaints from font-size animation: animating font-size triggers layout (expensive). Use transform: scale() for animation instead.
  2. CSS custom property resolution: Each var() call adds ~0.1ms per element. At hundreds of elements this is measurable but acceptable.
  3. 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

css
/* ============================================
   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;
}

"What I cannot create, I do not understand." — Richard Feynman