Fluid typography with clamp(), done properly

The interpolation maths with a worked example, why zoom breaks naive fluid type, and the habits that keep it accessible.

Guide · By AurumCodeWorks · 8 min read · Updated 12 October 2026

What clamp() actually does

clamp(MIN, PREFERRED, MAX) is resolved as max(MIN, min(PREFERRED, MAX)), as the MDN reference for clamp() puts it. For type, MIN is the size you want on small screens, MAX is the size you want on large screens, and PREFERRED is a value that grows with the viewport. Between two chosen viewport widths the text scales in a straight line; outside them it holds at the bound.

That replaces a ladder of media queries with one declaration, and it removes the jumps between breakpoints. The catch is that the preferred value is a formula you have to work out, and getting it wrong is how most fluid type ends up either inaccurate or inaccessible.

The maths: slope and intercept

You pick four numbers: the minimum font size, the maximum font size, the viewport width where the minimum should apply, and the viewport width where the maximum should apply. The preferred value is the straight line through those two points, so it is ordinary y = mx + c:

slope     = (maxSize − minSize) ÷ (maxViewport − minViewport)
intercept = minSize − slope × minViewport
preferred = intercept + (slope × 100)vw

The slope is multiplied by 100 because 1vw is 1% of the viewport width. The intercept comes out in pixels; divide it by 16 to express it in rem at the default root size, which is exactly what our px to rem converter does.

A worked example

Say a page heading should be 32px (2rem) on a 360px phone and 56px (3.5rem) on a 1280px laptop.

slope     = (56 − 32) ÷ (1280 − 360) = 24 ÷ 920 = 0.026087
vw part   = 0.026087 × 100 = 2.6087vw
intercept = 32 − (0.026087 × 360) = 32 − 9.3913 = 22.6087px = 1.4130rem

h1 {
  font-size: clamp(2rem, 1.413rem + 2.6087vw, 3.5rem);
}

Sanity check: at 360px the preferred value is 22.6087 + 9.3913 = 32px, and at 1280px it is 22.6087 + 33.3913 = 56px. Both ends land where they should. Here is what the browser computes across a range of widths, generated with a short Node script that reproduces the same arithmetic at 100% zoom and a 16px root:

ViewportPreferred valueUsed sizeIn rem
320px30.96px32.00px (min)2.000rem
360px32.00px32.00px2.000rem
480px35.13px35.13px2.196rem
768px42.64px42.64px2.665rem
1024px49.32px49.32px3.083rem
1280px56.00px56.00px3.500rem
1440px60.17px56.00px (max)3.500rem
1920px72.70px56.00px (max)3.500rem

The preferred column shows why the bounds earn their place: left alone, the line would keep climbing to 72.7px on a 1920px monitor. Our clamp() generator does this arithmetic and the rounding for you, and also produces a full type scale and a zoom check.

Why the preferred value must mix rem and vw

It is tempting to skip the intercept and write font-size: 4vw. There are two problems with that. First, browser zoom does not change how wide the viewport physically is, so a pure vw size does not respond to zoom at all. By our calculation, 4vw on a 1440px-wide screen renders at 57.6px at 100%, 200%, 300%, 400% and 500% zoom alike. WCAG lists exactly this as failure F94, incorrect use of viewport units to resize text.

Second, vw ignores the default font size people set in their browser. Someone who raises it from 16px to 20px expects text to get 25% bigger. Rem respects that; viewport units do not. Mixing the two means the rem part tracks both zoom and the user's preference, while the vw part supplies the fluid growth.

Be honest with yourself about how far that goes. At a 1024px viewport our example heading moves from 49.32px to 54.97px when the default is raised to 20px: an 11% increase rather than 25%, because the vw portion does not move. The min and max bounds, being pure rem, scale fully. Avoid px in the intercept for the same reason: pixels follow zoom but ignore the font-size setting.

The zoom problem: WCAG 1.4.4

Success Criterion 1.4.4 Resize Text (level AA) asks that text can be resized “up to 200 percent without loss of content or functionality” (W3C, Understanding SC 1.4.4). Fluid type complicates this because zoom only scales part of the formula. At a zoom factor z, the rem intercept and both bounds are multiplied by z, but the vw term stays the same physical size. On a wide screen, where the text was already sitting at its maximum, 200% browser zoom simply does not double it.

Adrian Roselli documented this in Responsive Type and Zoom, including a published clamp() example that grew only around 6% at 200% zoom. Maxwell Barvian later worked through the maths in Addressing Accessibility Concerns With Using Fluid Type (Smashing Magazine, 2023) and arrived at a rule of thumb: keep the maximum font size at or below 2.5 times the minimum.

The reasoning is short. Most browsers let users zoom to 500%. At 500% the smallest the text can render is five times the minimum, because the minimum bound scales with zoom. If the maximum is no more than 2.5 times the minimum, then five times the minimum is at least twice the maximum, so whatever size the text was at 100%, it can always be doubled. The same logic applies to any font size with known bounds, not just clamp().

Treat it as what it is: a heuristic, and a sufficient condition rather than a WCAG requirement. It assumes a 500% zoom ceiling, it says nothing about how much zoom a user needs before the text doubles, and Roselli notes in an update to his post that he has not tested Barvian's assertions. Use the rule to rule out bad designs, then zoom and measure.

Checking two clamps under zoom

To see the rule in practice, we ran two clamps through the same model on a 1440px-wide screen. Sizes are the rendered size relative to the 100% view. Clamp A is the worked example above (maximum ÷ minimum = 1.75). Clamp B spans the same viewports but goes from 1.5rem to 4.5rem (a ratio of 3): clamp(1.5rem, 0.3261rem + 5.2174vw, 4.5rem).

ZoomA: sizeA: vs 100%B: sizeB: vs 100%
100%56.0px1.00×72.0px1.00×
200%82.8px1.48×85.6px1.19×
300%105.4px1.88×90.8px1.26×
400%128.0px2.29×96.0px1.33×
500%160.0px2.86×120.0px1.67×

Clamp A passes in the sense the rule promises: it reaches double size at about 330% zoom. Clamp B never gets there. At 500% it is only 1.67×, because its 1.5rem floor times five is 120px, less than twice its 72px ceiling. Notice that neither clamp doubles at 200% browser zoom on a wide screen.

On a 390px phone both clamps sit near their minimum, so zoom behaves almost linearly: A doubles at about 205% and B at about 214%. Wide screens are where fluid type struggles, and they are exactly where most of us forget to test zoom. If you want text to double by 300%, the same reasoning tightens the ratio to 1.5.

Fluid type scales

One clamp is rarely enough; you want a whole scale. In a modular scale each step is the previous one multiplied by a ratio. The approach popularised by Utopia goes further and lets the ratio itself change: a tighter ratio on small screens, where huge headings waste space, and a wider one on large screens. You define a base size and ratio at each end, compute every step's minimum and maximum, and interpolate each step with the same slope and intercept formula.

Here is a scale running from a 16px base at a 1.2 ratio on a 360px viewport to an 18px base at a 1.25 ratio on a 1280px viewport:

:root {
  --step--1: clamp(0.8333rem, 0.8072rem + 0.1159vw, 0.9rem);
  --step-0: clamp(1rem, 0.9511rem + 0.2174vw, 1.125rem);
  --step-1: clamp(1.2rem, 1.1193rem + 0.3587vw, 1.4063rem);
  --step-2: clamp(1.44rem, 1.3156rem + 0.5527vw, 1.7578rem);
  --step-3: clamp(1.728rem, 1.5444rem + 0.8161vw, 2.1973rem);
  --step-4: clamp(2.0736rem, 1.8103rem + 1.1704vw, 2.7466rem);
}

body  { font-size: var(--step-0); }
h1    { font-size: var(--step-4); }
h2    { font-size: var(--step-3); }
small { font-size: var(--step--1); }

A useful side effect: the maximum-to-minimum ratio stays modest at every step. Even step 4 grows only 1.33×, comfortably inside the 2.5× limit. Ratios that break the rule usually come from designs that want a 24px mobile heading to become 96px on desktop, and a scale keeps that ambition in check.

Container query units for components

Viewport units tie type to the window, but a card in a narrow sidebar and the same card in a full-width grid share one viewport. The cqi unit is 1% of the query container's inline size, so swapping vw for cqi sizes text to the component instead:

.card-wrapper {
  container-type: inline-size;
}

.card h2 {
  font-size: clamp(1.25rem, 0.9rem + 2.5cqi, 2rem);
}

The maths is identical, with container widths in place of viewport widths. Two cautions from MDN's container queries guide: the units only resolve against an ancestor with a container-type of size or inline-size, and if no eligible container exists they fall back to small viewport units, so a missing declaration silently gives you viewport behaviour. And for a fluid-width container the cqi term behaves much like vw under zoom, so the 2.5× rule still applies.

Common mistakes

  • Minimum larger than maximum. Because clamp() resolves as max(MIN, min(VAL, MAX)), a MIN above MAX wins everywhere and the text never changes. This usually happens when someone wants text to shrink as the screen grows. Keep the smaller value first and let the slope go negative instead.
  • Pixels in the preferred value. clamp(1rem, 12px + 2vw, 2rem) works, but the 12px ignores the user's font-size setting. Convert the intercept to rem.
  • Line height that doesn't scale. A fixed line-height: 28px will make lines collide once the text outgrows it. A unitless value scales, but 1.5 looks loose on large headings. line-height: calc(1em + 0.5rem) gives 1.5 at 16px and about 1.14 at 56px, tightening as the text grows.
  • Forgetting line length. Fluid body text in a full-width container can run well past 100 characters a line on a desktop monitor. Cap the measure with max-inline-size: 65ch; because ch is relative to the font, the measure stays consistent as the size changes.
  • Checking contrast at only one size. WCAG's lower contrast requirement for large text depends on the rendered size, so a fluid heading can count as large on desktop and normal on a phone. Check it against the smaller size with our contrast checker.

A sensible default

Set your bounds in rem, build the preferred value from a rem intercept plus a vw slope, and keep each maximum within 2.5 times its minimum, tighter for body text, which rarely needs to grow by more than an eighth. Pair the sizes with a scaling line height and a ch-based measure. Then open a wide browser window, zoom to 200%, 300% and 400%, and confirm with your own eyes that the text grows. The maths gets you close; the zoom test is what tells you it works.

Frequently asked questions

What is the formula for fluid typography with clamp()?

Slope = (max size − min size) ÷ (max viewport − min viewport), and intercept = min size − slope × min viewport. The preferred value is the intercept in rem plus slope × 100 in vw. Going from 32px at a 360px viewport to 56px at 1280px gives clamp(2rem, 1.413rem + 2.6087vw, 3.5rem).

Is fluid typography bad for accessibility?

Not inherently, but it can fail WCAG 1.4.4 Resize Text if browser zoom cannot double the text. Set the bounds in rem, mix rem with vw in the preferred value, keep the maximum at or below 2.5 times the minimum, and test by zooming a wide browser window.

Why use rem and vw together inside clamp()?

A pure vw value ignores both browser zoom and the user's default font size, because it only tracks the viewport. The rem part responds to both settings, while the vw part provides the fluid growth between your two breakpoints.

Should I use vw or cqi for fluid font sizes?

Use vw for page-level type such as hero headings and body copy. Use cqi for components that appear in containers of different widths, such as cards. cqi needs an ancestor with container-type: inline-size; without one it falls back to small viewport units.