Fluid Typography Without JavaScript: A Precision CSS Reference

Problem statement

A type scale is a small set of sizes — caption, body, lead, three or four heading levels — with a consistent ratio between neighbours. Making that scale fluid means every step interpolates smoothly between a narrow-screen value and a wide-screen value, and the hard part is not making one heading fluid; it is making seven sizes fluid together so the relationships between them survive the interpolation. For years the standard answer was a script: measure the viewport, compute the sizes, write them to the root as custom properties, and repeat on every resize. This page builds the same scale with nothing but clamp() and shows why the arithmetic has to be done per step rather than once. It belongs to Fluid Typography with clamp() within Mastering Container Queries & Responsive Layouts.

Approach rationale

The case against the script is not that it is slow, though it is. It is that it runs at the wrong time. A resize listener can only fire after the browser has already laid out and painted a frame at the new width, so the sequence is always paint wrong, measure, restyle, paint again. On load that shows up as text appearing at the fallback size and then jumping; during a drag-resize it shows up as type that lags the window edge. Neither is fixable by optimising the handler, because the ordering is inherent — the script's input is the layout it is trying to influence. CSS math functions are evaluated as part of computing the used value, inside the very layout pass that established the viewport width, so the first frame is already correct and there is never a second one.

The script also fails in ways that have nothing to do with rendering. It does nothing until the bundle parses and executes, so it is dead weight during the period where readers are most likely to see text. It cannot run in an email client, a srcdoc iframe, or any context with a restrictive script policy. And it makes typography a runtime concern in a system where every other visual property is declarative, which means type sizes stop being inspectable in DevTools' styles panel and start being a thing you have to reason about in a debugger.

Where a script still wins is genuinely non-linear behaviour — fitting a headline to an exact number of lines, or balancing a ragged edge by measurement. Those are per-string problems that no amount of viewport arithmetic solves, and modern CSS covers a good part of that ground anyway with text-wrap: balance and text-wrap: pretty.

One boundary worth marking: this page is about constructing the scale. The separate question of whether a given fluid value survives a user zooming to 200% — and the trap of writing a preferred value entirely in vw — is worked through in fluid type, accessibility and zoom. The scale below is written to satisfy that constraint, and the reasoning for why the rem term is mandatory lives there.

How clamp() bounds font size across viewport width Font size holds at the minimum on narrow screens, rises linearly through the middle range, and caps at the maximum on wide screens. clamp(min, preferred, max) viewport width min floor preferred slope max ceiling

Designing the scale before writing any CSS

A fluid scale is defined by four decisions, and making them explicitly is what keeps the stylesheet from becoming a pile of magic numbers.

The two reference widths. Pick the narrow and wide viewports at which the scale hits its bounds — 320px and 1440px are conventional and used throughout below. Every step shares this pair, which is the property that keeps the steps parallel.

The two ratios. A modular scale multiplies each step by a fixed ratio. Use a smaller ratio at the narrow width than at the wide one: 1.2 at 320px and 1.333 at 1440px is a good starting pair. A single ratio applied at both ends produces headings that are either timid on desktop or overwhelming on a phone, because the amount of contrast a layout can carry grows with its width.

The step range. Two or three negative steps below body for captions, labels and fine print; four or five positive steps above it. Seven or eight total covers almost every interface.

The base. Body text at 1rem at both reference widths. Body text is the one size that should barely move — it is already at the size readers chose, and a wider window does not make 16px harder to read.

From those, each step's minimum is base × narrow_ratio^n and its maximum is base × wide_ratio^n, where n is the step index. Then each step needs its own linear interpolation between those two values, which is the part that is easy to get wrong.


Complete working implementation

This is the whole system: an eight-step scale, the derived slopes, and a page that uses it. Save it as an HTML file and drag the window between roughly 320px and 1440px.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fluid type scale in pure CSS</title>
<style>
  :root {
    color-scheme: light dark;

    /* Every step is clamp(min, A rem + B vw, max) where:
         B  = (max - min) / (1440px - 320px) * 100      (the vw slope)
         A  = min - (B/100 * 320px), expressed in rem   (the rem intercept)
       The rem term is what lets the value respond to zoom and to a
       user-raised root size; the vw term is what makes it fluid.
       Minimums use a 1.2 ratio, maximums a 1.333 ratio. */

    /* Negative steps: deliberately near-flat. Small text is already at
       the readability floor, so it should not shrink further, and it
       gains nothing from growing on a large display. */
    --step--2: clamp(0.694rem, 0.68rem + 0.07vw, 0.75rem);
    --step--1: clamp(0.833rem, 0.81rem + 0.11vw, 0.917rem);

    /* Base: fixed. Body text tracks the reader's preference, not the window. */
    --step-0:  1rem;

    /* Positive steps: slope grows with the step, because the gap
       between min and max grows with the step. */
    --step-1:  clamp(1.2rem,  1.15rem + 0.24vw, 1.333rem);
    --step-2:  clamp(1.44rem, 1.33rem + 0.55vw, 1.777rem);
    --step-3:  clamp(1.728rem, 1.51rem + 1.09vw, 2.369rem);
    --step-4:  clamp(2.074rem, 1.68rem + 1.97vw, 3.157rem);
    --step-5:  clamp(2.488rem, 1.83rem + 3.29vw, 4.209rem);

    /* Line height moves inversely to size: big type needs less leading. */
    --leading-tight: 1.1;
    --leading-snug:  1.25;
    --leading-body:  1.6;
  }

  * { box-sizing: border-box; }

  body {
    margin: 0;
    padding: clamp(1rem, 0.5rem + 2.5vw, 3rem);
    font-family: system-ui, sans-serif;
    font-size: var(--step-0);
    line-height: var(--leading-body);
  }

  main { max-width: 65ch; margin-inline: auto; }

  /* Headings consume the scale by name. No numeric font-size anywhere
     below this point, which is what makes the scale a single source. */
  h1 { font-size: var(--step-5); line-height: var(--leading-tight);
       letter-spacing: -0.02em; text-wrap: balance; margin: 0 0 0.5em; }
  h2 { font-size: var(--step-3); line-height: var(--leading-snug);
       letter-spacing: -0.01em; text-wrap: balance; margin: 1.5em 0 0.4em; }
  h3 { font-size: var(--step-2); line-height: var(--leading-snug);
       margin: 1.25em 0 0.35em; }

  .lead    { font-size: var(--step-1); line-height: var(--leading-snug); }
  .caption { font-size: var(--step--1); opacity: 0.75; }
  .fine    { font-size: var(--step--2); opacity: 0.7; }

  p { text-wrap: pretty; }
</style>
</head>
<body>
  <main>
    <h1>A type scale that resizes itself</h1>
    <p class="lead">Every size on this page comes from one of eight tokens,
      and every token interpolates on its own slope.</p>
    <h2>Why the slopes differ</h2>
    <p>Drag the window wide and watch the heading pull away from the body
      text. That widening gap is the ratio increasing, which is the effect
      a shared slope would have destroyed.</p>
    <h3>Small text stays put</h3>
    <p class="caption">Captions barely move across the whole range.</p>
    <p class="fine">Fine print moves less still.</p>
  </main>
</body>
</html>

Check the arithmetic on one step rather than trusting it. For --step-3, the minimum is 1.728rem (27.65px) and the maximum 2.369rem (37.9px). The difference, 10.25px, is spread over 1120px of viewport, giving a slope of 10.25 / 1120 × 100 ≈ 0.92vw; the intercept is 27.65 − (0.0092 × 320) ≈ 24.7px ≈ 1.54rem. The token above reads 1.51rem + 1.09vw rather than 1.54rem + 0.92vw because a portion of the slope has been shifted into the vw term on purpose — a steeper vw with a smaller rem intercept reaches the maximum slightly earlier, which flatters mid-size laptops. Both forms hit the same values at both reference widths; the difference is only in the middle, and it is a legitimate place to exercise taste.


Key technique callout: one slope per step

The mistake that ruins most hand-rolled fluid scales is a shared slope — applying the same + 1vw to every token because it looks tidy. Adding a constant to every size preserves differences but destroys ratios, and typographic scale is a ratio system. Take a 1rem body and a 3rem heading, both given + 1vw: at a 1440px viewport each gains 14.4px, so body reads 30.4px and the heading 62.4px. The ratio has fallen from 3.0 to 2.05. Visually the heading has stopped being a heading and become slightly-larger text, and the effect is worse the further apart the two steps started.

Deriving each slope from that step's own minimum and maximum fixes it, because the amount each step gains is now proportional to how much room that step needed to travel. The large steps get steep slopes, the small ones get almost none, and the ratio at 1440px is the wide ratio you specified rather than whatever the arithmetic happened to leave behind. This is also why the base step is a bare 1rem with no clamp() at all: its minimum and maximum are equal, so its slope is zero, and writing clamp(1rem, 1rem, 1rem) would only obscure that.


Variation: a component-scoped copy of the same scale

The scale above is keyed to the viewport, which is right for page-level text. A card that can appear at three different widths on one page needs a scale keyed to the card. Swap the vw term for cqi and redeclare the tokens inside the component; because they are custom properties, the redeclaration inherits down and every rule that already reads var(--step-2) picks up the new value with no change.

.card {
  container-type: inline-size;

  /* Same shape, container-relative slope. 1cqi is 1% of the card's
     inline size, so these steps track the card and ignore the window. */
  --step-1: clamp(1.2rem,  1.1rem + 0.9cqi, 1.333rem);
  --step-2: clamp(1.44rem, 1.25rem + 1.7cqi, 1.777rem);
  --step-3: clamp(1.728rem, 1.4rem + 3cqi,   2.369rem);
}

/* Nothing else changes: the heading rule was already written
   against the token, not against a number. */

The token indirection is what makes this cheap. Had the headings carried literal clamp() calls, a container-scoped variant would mean duplicating every declaration; because they carry var(), overriding the tokens on one ancestor rewrites the whole scale for that subtree. The same substitution drives a fluid space scale with clamp(), which applies this token shape to margins, gaps and padding so vertical rhythm moves in step with the type.


Browser support note

Nothing in the scale is new. clamp() shipped across all four engines years ago, and custom properties predate it everywhere. The cqi unit in the component variant needs Chrome 105+, Edge 105+, Firefox 110+, and Safari 16+. The two text-wrap values are the only genuinely recent additions: balance is available in every current engine, while pretty has narrower support — both degrade to normal wrapping with no layout consequence, so neither needs a guard.

If you must support an engine without clamp(), ship a static rem value first and let the fluid one override it. Declaration order alone is enough; a browser that cannot parse the second declaration discards it and keeps the first:

:root {
  --step-3: 2rem;                                        /* parsed everywhere */
  --step-3: clamp(1.728rem, 1.51rem + 1.09vw, 2.369rem); /* wins where supported */
}

FAQ

Why does a CSS type scale beat a JavaScript one? CSS math resolves inside the layout pass that already knows the viewport width, so the first painted frame is already correct. A resize listener runs after that frame, which means a visible resize and an extra layout on every scale change. It also needs the bundle to have loaded, which is exactly when readers are most likely to be looking at text.

Does each step of a fluid type scale need its own clamp() slope? Yes. A shared slope makes every step grow by the same absolute amount, which compresses the ratio between adjacent steps as the viewport widens. Give each step a slope derived from its own minimum and maximum so the ratio holds. The visible symptom of getting this wrong is headings that stop looking like headings on wide screens.

Should a fluid type scale include steps smaller than the body size? Yes, but flatten them. Captions and labels are already near the readability floor, so give the negative steps a very small slope or none at all and let only the larger steps do the visible scaling. A caption that shrinks on a phone is a caption nobody reads.

How do I stop two adjacent scale steps from crossing over? Crossing happens when a lower step has a steeper slope than the step above it. Derive every slope from the same pair of reference widths, then check the computed values at both reference widths to confirm the order still holds. DevTools' computed panel shows the resolved pixel value for each token, which makes the check a two-minute job.

Related articles

More pages in the same section.