Type scales that survive real content

A ratio gives you the sizes, but only real content tells you whether they work. Test your scale against long German words and columns of numbers before you ship it.

Start with a ratio, not a list

Most type scales start life as a list someone typed: 12, 14, 16, 18, 20, 24, 30, 36. It holds up until the fourth heading level, when nobody can say why 30 and not 32. A modular scale swaps the list for two decisions: a base size and a ratio. Each step is the one below it multiplied by the ratio.

Here are the four ratios people actually use, from a 16px base. Step 0 is body text. Values are rounded to the nearest pixel.

Step1.1251.21.251.333
-21311109
-114131312
0 (body)16161616
118192021
220232528
323283138
426333951
529404967
632486190

Read it by column. 1.125 barely moves: six steps up gets you 32px, a modest heading. That suits dense product UI, where you need many levels and have little room. 1.333 reaches 90px in the same six steps, which is a marketing page, not a settings screen. Most product systems land on 1.2 or 1.25.

Check the small end too. On 1.333, step -2 is 9px, which nobody can read. Most systems stop one step below body, or two on a tight ratio.

Round to whole pixels; nobody can see 39.06px. Some teams snap to even numbers, so 1.25 becomes 12, 16, 20, 24, 32, 40, 48. That's no longer strictly modular, and that's fine. The ratio is how you make the decision once, not a law.

Line height shrinks as size grows

Body text needs room between lines so the eye can find the start of the next one: about 1.5 at 16px. Set a 49px headline at 1.5 and it reads as three separate lines that happen to sit near each other. As size goes up, line height as a ratio comes down.

Here is the 1.25 scale with line heights that follow that rule:

RoleSizeLine heightComputed
label13px1.418px
body16px1.524px
heading-sm20px1.428px
heading-md25px1.2832px
heading-lg31px1.237px
display-sm39px1.1545px
display49px1.154px

Write line height unitless in CSS (line-height: 1.5, not 24px). A unitless value is inherited as a ratio, so a child with a different font size gets a line height that fits it. A pixel value is inherited as pixels and goes wrong the moment someone nests a bigger heading inside.

Test display line heights on a headline that wraps. At 1.1, descenders on the first line shouldn't touch ascenders on the second. If they do, go up to 1.15. text-wrap: balance on headings stops the lonely last word, and it costs one line of CSS.

Keep lines 60-75 characters

Measure is line length. Past about 75 characters the eye loses its place on the way back to the start of the next line. Much under 45 and the reader is jumping lines every couple of words. For running text, 60-75 characters is the comfortable band. WCAG's AAA criterion 1.4.8 caps it at 80.

:root {
  --measure-body: 65ch;
}

.prose {
  max-width: var(--measure-body);
}

ch is the width of the zero glyph in the current font, so 65ch is an estimate, not a count. Paste a real paragraph and count one line before you trust it.

Make measure a type token, not a layout width. It depends on the font and size, not on the grid.

Three weights are enough

Regular (400) for text, medium or semibold (500 or 600) for labels and UI emphasis, bold (700) for headings. Most systems never need a fourth. Each extra weight is another file to load and another option to pick wrongly. A codebase with 300, 400, 500, 600, 700 and 800 in use got there one pull request at a time.

Ask for a weight you didn't load and the browser fakes it by thickening the regular, which looks muddy. font-synthesis: none turns that off, so the missing weight renders as regular and someone notices in review.

Weight and size both signal hierarchy. A heading three steps up the scale may not need bold as well.

Fluid type with clamp()

Instead of jumping at breakpoints, a size can grow with the viewport between a floor and a ceiling. This heading is 32px on a 360px-wide phone and 48px at 1280px, growing in a straight line between:

:root {
  /* 32px at a 360px viewport, 48px at 1280px */
  --font-heading-lg-size: clamp(2rem, 1.6087rem + 1.7391vw, 3rem);

  /* 16px at 360px, 18px at 1280px */
  --font-body-size: clamp(1rem, 0.9511rem + 0.2174vw, 1.125rem);
}

The maths, so you can make your own. The slope is (max size - min size) / (max viewport - min viewport): (48 - 32) / (1280 - 360) = 0.017391, which is 1.7391vw. The intercept is min size - slope × min viewport: 32 - 0.017391 × 360 = 25.739px, or 1.6087rem at a 16px root. Check both ends: at 360px, 25.739 + 6.261 = 32. At 1280px, 25.739 + 22.261 = 48.

[!WARNING] Keep a rem term in the middle value. Text sized in pure vw doesn't grow when someone zooms the browser, which fails WCAG 1.4.4 (resize text to 200%). A rule of thumb: keep the ceiling under about 2.5 times the floor so zoom still has room to work.

Body text barely needs to be fluid; 16 to 18px is plenty. Save the big ranges for display and headings.

Name type tokens by role

A type token is a bundle, not one number: size, line height, weight and family travel together. Name the bundle after what the text is for.

TokenUsed forOn the 1.25 scale
displayhero headlines, marketing49/54, 700
heading (sm, md, lg)page, section and card titles20/28 to 31/37, 700
bodyparagraphs, table cells, form values16/24, 400
labelbuttons, form labels, tabs, captions13/18, 500
codeinline code, IDs, token namesmono, one step below body
:root {
  --font-family-sans: "Source Sans 3", system-ui, sans-serif;
  --font-family-mono: "JetBrains Mono", ui-monospace, monospace;

  --font-body-size: 1rem;
  --font-body-line-height: 1.5;
  --font-body-weight: 400;

  --font-heading-md-size: 1.5625rem; /* 25px */
  --font-heading-md-line-height: 1.28;
  --font-heading-md-weight: 700;

  --font-label-size: 0.8125rem; /* 13px */
  --font-label-line-height: 1.4;
  --font-label-weight: 500;
}

Never name a type token after its size. --font-heading-md can move from 25px to 24px next quarter and every usage stays correct. --font-25 becomes a lie. Don't name them h1 to h6 either: the document outline and the visual size are separate decisions, and a sidebar h2 is often label-sized. More on this in naming design tokens.

Test with the content you'll get

Specimen text lies. "The quick brown fox" has short words, no numbers and never wraps badly. Before you sign off a scale, set it with this:

  • Long words. German will hand you Rechtsschutzversicherungsgesellschaften (39 letters) in a 320px card. Headings break first, because display sizes fit fewer characters per line.
  • Numbers in columns. Prices, dates and counts in a table. Proportional figures make columns wobble because a 1 is narrower than an 8. font-variant-numeric: tabular-nums lines them up, if the font has tabular figures. Many display faces don't, so check.
  • Your longest real title. Not "Heading". The actual longest page title in the product, at the smallest viewport your fluid scale supports.
  • Bold inside body text. A bold word mid-sentence shouldn't change the line height or push the line wider than the measure.
.content:lang(de) {
  hyphens: auto;
}

.content {
  overflow-wrap: break-word;
}

.data-table td {
  font-variant-numeric: tabular-nums;
}

hyphens: auto only works when the browser knows the language, so the lang attribute has to be right. overflow-wrap: break-word is the safety net for when it isn't.

[!TIP] Keep a page in your docs that renders every type token with real product copy: the longest title, a German paragraph, a table of prices. Look at it every time the scale changes.

In Flash, type lives under [[Foundations > Typography]]. You can see the "Fonts in use", pull in any Google font by name or upload a custom font, and edit sizes, weights and line heights as tokens. Ask Flash ({{⌘K}}) has quick actions for the common moves, like "Shuffle font pairing", "Bigger & bolder" and "Editorial". Each one comes back as a patch card you Apply or Discard, so you can try a direction without losing the scale you had.