Design

CSS clamp() and fluid typography: the math (and the accessibility trap)

Most tutorials treat `clamp()` as a black box: feed it three values, out comes text that "grows on its own." But the middle value is not magic, it is the equation of a straight line. Once you see that `clamp(MIN, VAL, MAX)` is `max(MIN, min(VAL, MAX))` with a linear interpolation in the middle, everything changes: you stop guessing numbers and start deriving them. And there is a part almost nobody mentions, using pure `vw` for `font-size` **breaks WCAG 1.4.4 (Resize Text)**, because the text stops responding to zoom. The fix is not to abandon fluid type; it is to add a `rem` component. Let us derive the formula, do the actual arithmetic and assemble the right `clamp()` in the [clamp calculator](tool:calculadora-clamp-css).

J-Kit13 min readIntermediate
  • CSS
  • Fluid typography
  • Accessibility
  • WCAG
  • Responsive design

Key takeaways

  • `clamp(MIN, VAL, MAX)` equals `max(MIN, min(VAL, MAX))`: the preferred value only applies between the two bounds.
  • The preferred value is a line: slope `m = (y2−y1)/(x2−x1)`, intercept `b = y1 − m·x1`, result `b(rem) + 100m(vw)`.
  • A `font-size` in pure `vw` breaks WCAG 1.4.4, the text does not scale with zoom. The `rem` component is what hands control back to the user.
  • Always test at 200% zoom and at a 320px-wide window before calling a clamp done.

clamp() is linear interpolation in disguise

The CSS Values and Units Module Level 4 specification defines `clamp(MIN, VAL, MAX)` as exactly equivalent to `max(MIN, min(VAL, MAX))`. Read it right to left: the `min(VAL, MAX)` keeps the value from exceeding the ceiling; the `max(MIN, ...)` keeps it from dropping below the floor. In the middle sits `VAL`, the "preferred value", and that is where the fluid behavior lives. If `VAL` were a constant, `clamp()` would be pointless; the curve only comes alive because `VAL` usually depends on screen width, through viewport units.

MIN (floor)
The smallest allowed value. On narrow screens, it wins.
VAL (preferred)
The fluid expression, the line that interpolates between the bounds. This is where `vw` comes in.
MAX (ceiling)
The largest allowed value. On wide screens, it wins.

Note that `clamp()` does no smoothing, curve or easing: between floor and ceiling the growth is strictly linear. If you want an accelerating progression, text that grows slowly and then takes off, `clamp()` alone will not give it; that is the territory of timing curves, which you shape in the cubic-bezier editor. For responsive type, though, the straight line is exactly what you want: predictable and easy to derive.

Deriving the formula from scratch

The problem: I want a minimum size `y1` at a width `x1` and a maximum size `y2` at a larger width `x2`, with a straight line connecting the two points. This is high-school geometry. The slope is the vertical change divided by the horizontal one; the intercept is where the line crosses the axis at `x = 0`. The only CSS subtlety is translating that line into units the browser understands: the part proportional to width becomes `vw` (1vw = 1% of the window width), and the fixed part becomes `rem`.

m = (y₂ − y₁) / (x₂ − x₁) [inclinação / slope] b = y₁ − m · x₁ [intersecção em px / intercept in px] font-size = clamp( y₁ , b_rem + (100·m)vw , y₂ )
y₁
minimum font size (the clamp floor)
y₂
maximum font size (the clamp ceiling)
x₁
viewport width where the font should sit at y₁
x₂
viewport width where the font should reach y₂
m
slope of the line, in px of font per px of width
b
intercept of the line at x = 0, in px
b_rem
the intercept b converted from px to rem (b ÷ root size)
Because `1vw` is 1% of the width, a width coefficient `m` (in px per px) becomes `100·m` in vw. And `b`, computed in px, is divided by the root font size to become `b_rem` (for example, divided by 16 if the root is 16px).
  1. Pick the four pointsSet `y1` and `x1` (the minimum pair) and `y2` and `x2` (the maximum pair). Do the arithmetic entirely in px.
  2. Compute the slope`m = (y2 − y1) / (x2 − x1)`. This number is tiny, thousandths, because it is px of font per px of width.
  3. Compute the intercept`b = y1 − m · x1`. It is the value of the line at `x = 0`; it can be positive or negative.
  4. Translate to CSSPreferred = `b_rem + (100·m)vw`. Assemble `clamp(y1, preferred, y2)` with y1 and y2 in rem.

Two examples with the numbers

Example 1, body text. I want 1rem (16px, with a 16px root) at a 320px window, rising to 2rem (32px) at a 1280px window. So `y1 = 16`, `x1 = 320`, `y2 = 32`, `x2 = 1280`. The slope is `m = (32 − 16) / (1280 − 320) = 16 / 960 = 0.016667`, which in vw is `100 × 0.016667 = 1.6667vw`. The intercept is `b = 16 − 0.016667 × 320 = 16 − 5.3333 = 10.6667px`, which in rem is `10.6667 ÷ 16 = 0.6667rem`. Result: `clamp(1rem, 0.6667rem + 1.6667vw, 2rem)`.

1.6667vwwidth coefficient (100·m)
0.6667remintercept b converted to rem
clamp(1rem, 0.6667rem + 1.6667vw, 2rem)final result of Example 1

Check it: at a 320px window, `1.6667vw = 5.3333px`, and adding `0.6667rem = 10.6667px` gives exactly 16px = 1rem. At a 1280px window, `1.6667vw = 21.333px` plus 10.6667px gives 32px = 2rem. The line passes through the two points we asked for, and `clamp()` freezes at the bounds outside the interval.

Example 2, a heading. I want 1.5rem (24px) at a 360px window and 3rem (48px) at a 1440px window. So `m = (48 − 24) / (1440 − 360) = 24 / 1080 = 0.022222`, that is `2.2222vw`. The intercept is `b = 24 − 0.022222 × 360 = 24 − 8 = 16px`, which in rem is exactly `16 ÷ 16 = 1rem`, a case where the numbers come out round. Result: `clamp(1.5rem, 1rem + 2.2222vw, 3rem)`. At 360px: `2.2222vw = 8px` plus 1rem (16px) = 24px = 1.5rem; at 1440px: `2.2222vw = 32px` plus 16px = 48px = 3rem.

:root {
  /* Example 1, body text: 16px (1rem) @ 320px  ->  32px (2rem) @ 1280px
     m = (32-16)/(1280-320) = 1/60 px/px   ->  100m = 1.6667vw
     b = 16 - (1/60)*320   = 10.6667px     ->  10.6667/16 = 0.6667rem */
  --fs-body: clamp(1rem, 0.6667rem + 1.6667vw, 2rem);

  /* Example 2, heading: 24px (1.5rem) @ 360px  ->  48px (3rem) @ 1440px
     m = (48-24)/(1440-360) = 1/45 px/px   ->  100m = 2.2222vw
     b = 24 - (1/45)*360   = 16px          ->  16/16 = 1rem */
  --fs-h1: clamp(1.5rem, 1rem + 2.2222vw, 3rem);
}

body { font-size: var(--fs-body); }
h1   { font-size: var(--fs-h1);   }
Both examples as custom properties. The rem term is mandatory, the why comes in the WCAG section.

The curve: plateau, ramp, plateau

Plotting font size against width makes what `clamp()` does obvious: a plateau at the minimum while the screen is narrow, a linear ramp across the chosen interval and a plateau at the maximum once the screen gets too wide. The chart below compares two clamps. The conservative one is Example 1 (16px→32px, between 320px and 1280px). The aggressive one is `clamp(1.5rem, 0.1207rem + 6.8966vw, 4rem)` (24px→64px, between 320px and 900px): a much steeper ramp that saturates early. Note both are straight lines between two knees, no smoothing.

0px16px32px48px64px2809401,600Viewport width (px)Font size (px)Conservative (16→32px)Aggressive (24→64px)
Font size (px) as a function of viewport width (px), for a conservative and an aggressive clamp.
View the data
xConservative (16→32px)Aggressive (24→64px)
28016px24px
32016px24px
48018.7px35px
64021.3px46.1px
80024px57.1px
96026.7px64px
1,28032px64px
1,60032px64px

An over-aggressive clamp has two problems: the font changes too fast between two nearby screen sizes (jarring when resizing) and it saturates early, wasting the range of larger widths. A conservative clamp breathes better. Rather than memorizing the numbers, generate your own and watch the curve in the tool below.

Enter the minimum and maximum pairs; the calculator returns the clamp() already with the rem term.Open the tool full page

The WCAG 1.4.4 trap

Here is the part the "text that grows" tutorials tend to skip. WCAG Success Criterion 1.4.4 (Resize Text), level AA, requires that text can be resized up to 200% without loss of content or functionality, without assistive technology, using only the browser's own controls. And a `font-size` in pure `vw` fails exactly that test. Viewport units are anchored to the window size, not to the user's font preference; when someone zooms text or raises the browser's base font size, the `vw` does not budge. The user hits "increase" and nothing happens.

There is a second important safeguard: do not set a ceiling (`MAX`) that is too low. If your `clamp()` maximum is below twice the base size, you prevent the user from reaching the 200% the standard asks for, even with the rem term. A safe rule of thumb: only set a maximum if it is at least twice the minimum, or accept having no maximum at all. And test for real, the criterion demands manual verification, resizing the text and confirming it actually grows.

  1. 2016The "CSS Locks" are born

    Mike Riethmuller popularizes the linear-interpolation formula with calc() and vw; Tim Brown dubs it CSS Locks. It is the same line clamp() bakes in today.

  2. 2019Roselli documents the zoom failure

    Adrian Roselli publishes "Responsive Type and Zoom," showing with tests that vw-anchored text stops the user from enlarging the font.

  3. 2020clamp() reaches browsers

    clamp(), min() and max() gain cross-browser support (Chrome 79, Firefox 75, Safari 13.1), turning the CSS lock into a native one-line function.

  4. 2022svw / lvw / dvw units

    The small, large and dynamic viewport units reach browsers (Safari 15.4, Firefox 101, Chrome 108) to handle the mobile address bar.

About those new units: `vw` equals `lvw` (the large viewport, with the address bar retracted). `svw` measures the small viewport (bar visible) and `dvw` tracks the change in real time. They exist because, on mobile, the address bar appears and disappears as you scroll, changing the usable width. For `font-size` this rarely matters, the horizontal difference is tiny, and `dvw` can even cause reflow while scrolling; the caution around these units weighs more on heights (`dvh`) and full-screen elements.

Beyond typography (and the review)

The same `clamp()` works for any property that takes a length: internal spacing, grid gaps and column widths that breathe with the screen without a stack of media queries. But there are details that only surface once you leave `font-size`. The accordion below gathers the three that cause the most bugs.

clamp() in padding, gap and column width

For spacing, the pattern is `padding: clamp(1rem, 5vw, 3rem)`, margins that grow with the screen but never touch the edges nor explode. For content widths, `width: clamp(45ch, 60vw, 75ch)` keeps the text line in a readable band (`ch` is the width of the "0" glyph). Here the rem component is optional: spacing and width are not subject to criterion 1.4.4, which is about text. Even so, mixing in a font-relative unit (rem/em/ch) tends to give more predictable results.

Why min() and max() alone rarely suffice

`min(a, b)` returns the smaller value and `max(a, b)` the larger one. A `width: min(90vw, 60rem)` caps the width at 60rem on large screens, with no floor; a `max()` guarantees a minimum, with no ceiling. What almost every responsive layout wants, though, is both at once: a floor and a ceiling with a fluid preference in the middle. Doing that with nested min() and max() means writing `max(MIN, min(VAL, MAX))`, which is literally the definition of clamp(). So: you can, but clamp() is the readable shortcut for the common case.

The trap of swapping MIN and MAX

If you swap the bounds, `clamp(MAX, VAL, MIN)` with the larger one first, CSS will not fix it for you. The spec resolves `max(MIN, min(VAL, MAX))` literally: when `MIN > MAX`, the inner `min()` returns MAX and the outer `max()` returns MIN, so the result locks to MIN and ignores everything. The text sticks at one size and you swear the clamp "does not work." Rule: the first argument is always the smallest, the third always the largest.

Before shipping, run through the review below. It covers the mistakes that do not show up on your 1440px monitor but blow up on someone's phone or under the zoom of a person who needs it.

  • The preferred value includes a rem term (the intercept b_rem), never pure vw on font-size.
  • I tested at 200% zoom and the text actually grew (WCAG 1.4.4).
  • I tested at a 320px-wide window and the font neither shrank to nothing nor broke the layout.
  • The maximum (MAX) is at least twice the minimum, or there is no maximum, so zoom is not capped before 200%.
  • MIN is the first argument and MAX the last; I checked I did not swap them.
  • I did not drop a raw clamp() into line-height without care: unitless line-height scales with the font; a clamp in px/rem freezes the line spacing.

Frequently asked questions

How does clamp() work in CSS?
`clamp(MIN, VAL, MAX)` is defined by the spec as `max(MIN, min(VAL, MAX))`: the result is the preferred value VAL, but never below MIN nor above MAX. In fluid typography, VAL is a line in terms of screen width (something like `0.66rem + 1.67vw`), so the font grows linearly between two viewport sizes and freezes at the bounds.
Why should I not use only vw for font-size?
Because it breaks WCAG 1.4.4 (Resize Text): viewport units are tied to the window size, not to the user's font preference. When someone zooms text or raises the base font, a `font-size: 4vw` does not change, the W3C documents this as failure F94. The fix is to add a rem term to the preferred value, because rem responds to zoom and the browser setting.
How do I calculate the values of a clamp() for font size?
Pick a minimum size y1 at a width x1 and a maximum y2 at a width x2. Compute the slope `m = (y2−y1)/(x2−x1)` and the intercept `b = y1 − m·x1` (all in px). The preferred value is `b_rem + (100·m)vw`, where b_rem is b divided by the root font size. The result is `clamp(y1, b_rem + 100m·vw, y2)`. The clamp calculator does this arithmetic automatically.
What is the difference between vw, svw, lvw and dvw?
`vw` is 1% of the viewport width and equals `lvw` (the large viewport, with the address bar retracted). `svw` uses the small viewport (bar visible) and `dvw` tracks the change in real time. They exist because of the mobile address bar, which appears and disappears as you scroll. For font-size the horizontal difference is small; the caution weighs more on heights (dvh) and full-screen elements.
Can I use clamp() for padding and width, not just font size?
Yes. Any property that takes a length works: `padding: clamp(1rem, 5vw, 3rem)` for fluid spacing, `width: clamp(45ch, 60vw, 75ch)` for a readable text column, grid gaps, and so on. The rem-term requirement is specific to `font-size` (because of WCAG 1.4.4); for spacing and width it is optional, though mixing rem/em/ch still helps predictability.

`clamp()` is a straight line with two brakes: derive the slope and intercept from your minimum and maximum pairs, and never write the preferred value in vw alone. The rem term is not decoration, it is what keeps the text obedient to zoom and to WCAG 1.4.4. Do the math once, test at 200% and at 320px, and you have fluid typography that is also accessible.

Sources & references

  1. MDN, clamp() (CSS Values and Units)
  2. W3C, Understanding SC 1.4.4: Resize Text
  3. W3C, F94: failure of 1.4.4 due to incorrect use of viewport units
  4. Adrian Roselli, Responsive Type and Zoom (2019)
  5. MDN, length units (vw, svw, lvw, dvw)
  6. web.dev, The large, small, and dynamic viewport units