🍱 Lunchbox Hands

css

CSS clamp() Fluid Typography: The Math, and the Zoom Trap

A fluid font-size is a straight line through two points — slope, intercept, done. The derivation with real numbers, why the intercept must be rem and not px, what the spec says when min is bigger than max, and the WCAG 1.4.4 failure that a vw-only value walks straight into.

Every fluid-typography tutorial hands you the same shape — font-size: clamp(1rem, 0.8333rem + 0.8333vw, 1.5rem) — and almost none of them say where the two 0.8333s came from, why the middle term has a rem in it at all, or what happens to that text when a reader presses Ctrl-plus three times.

The middle term of a fluid clamp() is nothing more exotic than the equation of a straight line, y = mx + b, with the viewport width as x. Once you see that, the numbers stop being magic, negative intercepts stop looking like bugs, and the accessibility problem becomes obvious: a line with no rem in it has no way to respond to zoom. Here is the derivation with real numbers, then the trap, then exactly what our clamp() calculator does and does not do about it.

The math: a line through two points

You have two requirements, expressed as points on a graph where the x-axis is viewport width and the y-axis is font size:

  • at a 320px viewport the text should be 16px
  • at a 1280px viewport the text should be 24px

Between those two points you want the font size to change linearly. Two points define a line, and a line is size = slope × viewport + intercept. So:

slope     = (maxSize − minSize) / (maxViewport − minViewport)
          = (24 − 16) / (1280 − 320)
          = 8 / 960
          = 0.008333…            (px of font size per px of viewport)

intercept = minSize − slope × minViewport
          = 16 − 0.008333 × 320
          = 16 − 2.6667
          = 13.3333 px

Sanity-check it at the two ends and one point in the middle (these are computed, not eyeballed):

Viewport13.3333 + 0.008333 × viewport
320px16.00px
640px18.67px
800px20.00px
1024px21.87px
1280px24.00px

That is the entire derivation. Everything after this is unit conversion.

Turning the line into CSS

CSS has no “viewport width” variable, but it has vw, which the CSS Values 4 draft defines as “1% of the width of the large viewport size.” So 1vw = viewport / 100, which means slope × viewport becomes (slope × 100)vw:

slope × 100 = 0.8333vw

The intercept is a plain length, and here is the first decision that matters: express it in rem, not px. Dividing by a 16px root gives 13.3333 / 16 = 0.8333rem. (It is a coincidence of this particular example that the intercept in rem and the slope in vw are both 0.8333 — pick 15px at 400px to 22px at 1300px and you get 0.7431rem + 0.7778vw.) If you’re converting a whole design’s pixel values rather than one intercept, the px to rem converter does the same division against any root size, and a CSS px is not a device pixel covers what those pixels and rems actually measure.

The min and max become 1rem and 1.5rem, and the finished value is:

font-size: clamp(1rem, 0.8333rem + 0.8333vw, 1.5rem);

Below 320px the preferred value would drop under 16px, so the 1rem floor takes over; above 1280px it would exceed 24px, so the 1.5rem ceiling takes over. Between them the line runs. The rounding to four decimals costs you almost nothing — at 1280px the rounded expression evaluates to 0.8333 × 16 + 0.8333 × 12.8 = 23.999px, a thousandth of a pixel short of the target.

Why not 13.3333px + 0.8333vw? It produces identical pixels at 100% zoom, and it is what breaks accessibility. Hold that thought for two sections.

Negative intercepts are normal

Make the slope steep — say 16px at 320px climbing to 40px by 480px — and the intercept goes negative:

slope     = 24 / 160 = 0.15  →  15vw
intercept = 16 − 0.15 × 320 = −32px  →  −2rem
font-size: clamp(1rem, -2rem + 15vw, 2.5rem);

People see the minus sign and assume the calculator broke. It didn’t: the line through those two points simply crosses the y-axis below zero, which is just a statement about where the line would be at a 0px viewport — a width that never exists inside the clamp range. Inside the range the value is exactly what you asked for (-32 + 0.15 × 320 = 16; -32 + 0.15 × 480 = 40). A negative intercept is a sign that the ramp is steep, not an error, though it is worth noticing: a steep ramp is the same thing as a middle term dominated by vw, which — again — is the zoom problem.

Our calculator’s tests explicitly cover the sign handling: a negative slope is emitted as 1.6667rem - 0.8333vw, never as the invalid-looking + -0.8333vw.

Argument order, and what happens when min > max

clamp() takes its arguments as clamp(MIN, VAL, MAX) — minimum first, preferred value in the middle, maximum last. Put the preferred value first and you get an expression that parses fine and does something you did not intend.

What if the minimum is larger than the maximum? The CSS Values 4 draft resolves it in one clause: clamp() “represents its central calculation, clamped according to its min and max calculations, favoring the min calculation if it conflicts with the max. (That is, given clamp(MIN, VAL, MAX), it represents exactly the same value as max(MIN, min(VAL, MAX)).”

Work that through with a conflicting pair: clamp(2rem, 1rem, 1.5rem) is max(2rem, min(1rem, 1.5rem)) = max(2rem, 1rem) = 2rem. The min wins outright, the preferred value is ignored, and you have silently locked the font size to the first argument. (The draft also allows the keyword none for either bound — clamp(none, VAL, MAX) equals min(VAL, MAX) — but check support before relying on it.)

Our calculator sidesteps the conflict: enter a min size larger than the max (a heading that shrinks as the viewport grows is a legitimate design) and it sorts the bounds so the first argument is always the smaller, with a negative slope in the middle term carrying the behaviour.

The zoom trap: WCAG 1.4.4

WCAG 2.2 Success Criterion 1.4.4 Resize Text, Level AA, is one sentence: “Except for captions and images of text, text can be resized without assistive technology up to 200 percent without loss of content or functionality.” The Understanding document adds that “the scaling of content is primarily a user agent responsibility” and that content passes “if it can be scaled up to 200% using at least one text scaling mechanism supported by user agents” — in practice, browser zoom.

Now think about what browser zoom does mechanically. At 200% zoom, one CSS pixel is drawn as two device pixels — so the viewport is half as many CSS pixels wide: a 1280-device-pixel window becomes a 640-CSS-pixel viewport. 1vw is now 6.4 CSS px drawn as 12.8 device px — exactly the same device pixels as 1vw at 100%. Viewport units are anchored to the physical window, not the zoom level. font-size: 1.875vw (24px at 1280) is 24 device pixels at every zoom the browser offers. It never grows.

WCAG names this failure directly, F94: “As these units are relative to the viewport, it means they cannot be resized by zooming or adjusting text-size. […] Attempts to use browser controls to zoom or adjust text-size will not work.” It also draws the line that matters here: “if they are used as the primary method for defining text-size, they are likely to cause a failure.”

This is what the rem intercept is for. In 0.8333rem + 0.8333vw, the rem term is a CSS-pixel length, so at 200% zoom it draws at twice the device pixels while the vw term stays put. The text grows — not 1:1, but the larger the rem share of the total, the closer it gets. A px intercept would also double with zoom, but it ignores the other resize mechanism, a user-set default font size in browser settings, which rem follows and px does not.

The same 24px-at-1280 heading under three techniques, in a 1280-device-pixel window (computed from the definitions above, DPR 1):

Browser zoomclamp(1rem, 0.8333rem + 0.8333vw, 1.5rem)1.875vw onlyMedia-query rem steps (1rem below 768px, 1.5rem above)
100%24 device px2424
150%30.7 (128%)24 (100%)36 (150%)
200%37.3 (156%)24 (100%)32 (133%) — the mobile breakpoint kicked in
300%50.7 (211%)24 (100%)48 (200%)
400%64 (267%) — the 1rem floor is now in charge24 (100%)64 (267%)

Two honest observations. First, the rem + vw mix does not reach 200% at 200% zoom with this pair — it gets there around 300%, which every major browser offers, and it grows monotonically, which is what F94’s test procedure checks. If your audit wants 200% at 200%, move more of the size into the rem term: a gentler slope (a wider viewport range for the same size change) does exactly that. Second, media-query steps are not automatically better: at 200% zoom the viewport is 640 CSS px, so the mobile size applies — F94’s note says media queries “may not be a failure,” not that they are ideal.

TechniqueZoom-safe (grows with Ctrl-plus)Follows user font-size preferenceSmooth between breakpointsPredictable at any width
Media-query steps in rem (Tailwind’s text-* classes are these)Yes, 1:1YesNo — jumps at each breakpointYes
vw only (font-size: 1.875vw)No — fails F94NoYesNo — unbounded at wide screens
clamp(rem, rem + vw, rem)Yes, partially — the rem share growsYesYesYes — bounded both ends

The last row is the one to ship. The middle row is the one a surprising number of “fluid type” snippets still teach.

100vw, the scrollbar, and the new viewport units

Per the CSS Values 4 draft, unless the root element forces a scrollbar unconditionally (overflow: scroll), “the viewport-percentage lengths are sized assuming that scrollbars do not exist (even if this diverges from the initial containing block).” On platforms with a classic non-overlay scrollbar — Windows, most obviously — 100vw is wider than the space your content actually has, by the scrollbar’s width. For a font size this is noise (a 17px scrollbar shifts 0.8333vw by about 0.14px), but it is why width: 100vw produces horizontal scroll on those machines.

The same section defines svw, lvw, and dvw as 1% of the small, large, and dynamic viewport widths, and says the default vw is “defined with respect to the large viewport size” — retractable browser UI hidden. Address bars retract vertically, so for width the variants almost always agree; plain vw is fine for fluid type.

Browser support

clamp() is not new. MDN lists it as Baseline Widely available, across browsers since July 2020, and the caniuse entry for CSS math functions puts it at Chrome 79, Edge 79, Firefox 75, Safari 13.1 and iOS Safari 13.4, with global support around 96%. The holdouts are IE and Opera Mini. A font-size: 1rem fallback declared on the line before costs nothing if you still ship to either.

What our calculator does, exactly

Read from its source, the clamp() calculator:

  • Takes five pixel inputs: min/max viewport, min/max size, and root font size (default 16). There is no rem input.
  • Emits clamp(<min>rem, <intercept>rem + <slope>vw, <max>rem) — bounds and intercept in rem, slope in vw, never a px intercept.
  • Rounds every number to four decimal places, which is why the default reads 0.8333 and lands at 23.999px.
  • Handles equal viewports (constant rem, no vw term), a min size above the max (bounds sorted, negative slope), and a blank root (falls back to 16).
  • Does not warn when the vw term dominates, and does not simulate zoom. The preview follows the window; to see zoom behaviour, press Ctrl-plus on the tool page — the preview is styled with the generated value, so what you see is what your users get.

Where to go from here

Paste your own two points into the clamp() calculator, then read the middle term the way this post reads it: at your widest breakpoint, how much of the size is rem and how much is vw? If the answer is “mostly vw,” widen the viewport range or narrow the size range until the balance flips. The CSS formatter will tidy the result into a larger stylesheet, and if you are migrating from Tailwind’s fixed text-* steps, the Tailwind to CSS converter shows the rem values those steps map to. If the rule lives in a preprocessor rather than plain CSS, compile it with SCSS → CSS or LESS → CSS and read the output: clamp() is a browser function, but the variables and arithmetic you wrapped around it are not, and the compiled declaration is the only version the browser ever sees. (If that compile prints a wall of @import deprecation warnings, here’s how to migrate to @use.)

Related reads: the WCAG contrast ratio explainer covers the other Level AA criterion design systems most often fail; the CSS easing functions guide gives cubic-bezier() the same read-the-math treatment, and the aspect-ratio vs padding-bottom hack post does it for the ratio percentages; and if a late-loading web font makes your fluid heading jump on first paint, that is a layout-shift problem — Core Web Vitals explained covers CLS.