Fluid typography with clamp()
A heading at 40px looks right on a laptop and cramped on a 360px phone. The obvious
fix is a media query per size, per breakpoint, which is a lot of declarations for
"make it a bit smaller". clamp() does the whole thing in one line.
clamp()
h1 {
font-size: clamp(1.75rem, 5vw, 3rem);
}
Three arguments: minimum, preferred, maximum. The browser uses the preferred value unless it falls outside the bounds.
- At 320px,
5vwis 16px — below the 1.75rem (28px) floor, so it is 28px. - At 800px,
5vwis 40px — inside the range, so it is 40px. - At 1600px,
5vwis 80px — above the 3rem (48px) ceiling, so it is 48px.
One declaration, smooth at every width between, and no breakpoint to test at.
clamp(a, b, c) is exactly max(a, min(b, c)). If you ever need to reason about it,
that is the definition.
The accessibility trap, which matters
h1 { font-size: 5vw; } /* wrong */
h1 { font-size: clamp(1.75rem, 5vw, 3rem); } /* better */
A font size in vw alone ignores the visitor's font-size setting entirely. Somebody
who has set their browser to 24px because they cannot read small text gets no benefit:
5vw is 5% of the viewport regardless.
It also fails a WCAG requirement. Success criterion 1.4.4 asks that text can be
resized to 200% without loss of content, and pure vw text does not resize at all
when the user zooms text only.
clamp() with rem bounds is better — the floor and ceiling respect the setting — but
the middle value still does not. The properly accessible form adds a rem component to
the preferred value:
h1 {
font-size: clamp(1.75rem, 1.25rem + 2.5vw, 3rem);
}
Now the preferred value is partly in rem, so it scales with the visitor's
setting as well as with the viewport. The rule:
Always include a rem term in the middle argument of a clamp() used for font
size. Something like 1rem + 2vw rather than 4vw. This is the detail almost every
fluid-type tutorial omits.
Working out the numbers
You usually know what you want at two widths: say 18px at 360px and 24px at 1280px.
Rather than guessing at vw:
slope = (24 - 18) / (1280 - 360) = 0.00652
vw part = slope * 100 = 0.652vw
rem part = (18 - 360 * slope) / 16 = (18 - 2.348) / 16 = 0.978rem
font-size: clamp(1.125rem, 0.978rem + 0.652vw, 1.5rem);
That is linear interpolation, and it hits exactly 18px at 360 and 24px at 1280. You do
not need to do this arithmetic by hand every time — utopia.fyi generates a whole
scale — but doing it once tells you what the numbers mean, which matters when a
generated value looks wrong.
A fluid type scale
Do not clamp each heading independently; you will get a scale that inverts somewhere. Define the steps once:
:root {
--step--1: clamp(0.833rem, 0.80rem + 0.16vw, 0.889rem);
--step-0: clamp(1rem, 0.96rem + 0.22vw, 1.125rem);
--step-1: clamp(1.2rem, 1.14rem + 0.31vw, 1.406rem);
--step-2: clamp(1.44rem, 1.35rem + 0.44vw, 1.758rem);
--step-3: clamp(1.728rem, 1.60rem + 0.62vw, 2.197rem);
--step-4: clamp(2.074rem, 1.90rem + 0.87vw, 2.746rem);
}
body { font-size: var(--step-0); }
h3 { font-size: var(--step-2); }
h2 { font-size: var(--step-3); }
h1 { font-size: var(--step-4); }
Every step has the same ratio at every viewport width, so the relationships hold. Check the extremes: at 320px and at 1920px, is each heading still clearly bigger than the one below it? If two steps cross over, your slopes differ too much.
Fluid spacing too
The same tool, and it is underused:
.section { padding-block: clamp(2rem, 6vw, 5rem); }
.container { padding-inline: clamp(1rem, 4vw, 3rem); }
.stack { gap: clamp(1rem, 3vw, 2rem); }
Generous whitespace on a desktop looks wasteful on a phone, and phone-sized padding
looks mean on a desktop. This fixes both without a breakpoint. For spacing, the pure
vw accessibility concern does not apply the same way — spacing is not text — but a
rem term still helps it scale for somebody using larger text.
min() and max()
.container { width: min(100%, 65ch); } /* like a max-width */
.hero { padding: max(2rem, 5vw); } /* a floor */
They read backwards at first. min() behaves like a maximum — "the smaller of
these" is a ceiling on the result. max() behaves like a minimum — a floor.
width: min(100%, 65ch) is the readable-column pattern and is better than
max-width: 65ch plus width: 100%, because it is one declaration that cannot get
out of sync.
And a genuinely useful one:
.full-bleed {
width: min(100% - 2rem, 70rem);
margin-inline: auto;
}
A container that is 70rem wide when there is room and otherwise the full width minus a
1rem gutter each side, so text never touches a phone's edge. One declaration replacing
a width, a max-width and a padding.
Line length is still the point
A heading that scales is nice. A paragraph that never exceeds 65 characters matters more, from the typography lesson:
.prose {
width: min(100%, 65ch);
font-size: var(--step-0);
line-height: 1.6;
}
ch is itself fluid — it is relative to the font size, which is relative to the
viewport and to the visitor's setting. So the column widens as the text grows and the
character count stays right, with no query.
Do not make line-height fluid. It is already a ratio of the font size, so it
scales automatically. A clamped line-height is one of the few genuinely pointless
uses of it.
Where not to use clamp()
- Borders. A 1px border should stay 1px.
line-height, as above.- Anything that must hit an exact value, like a 44px touch target — use
max()for a floor instead. - When the layout must genuinely rearrange.
clamp()scales; it does not move things. A sidebar going below the content is still a media query or a container query.
And the honest limit: fluid values are linear between the bounds. If you want a size to hold steady and then jump, that is a breakpoint, and a breakpoint is the right tool for it.
Check your work
The three arguments. Minimum, preferred, maximum — and clamp(a,b,c) is
max(a, min(b, c)).
Why pure vw font size is wrong. It ignores the visitor's font-size setting
entirely, and fails WCAG 1.4.4.
The rule for the middle argument. Always include a rem term — 1rem + 2vw, not
4vw.
How to get the numbers. Linear interpolation between two known sizes at two known widths.
Why define a scale rather than clamping each heading. Independent slopes make the scale invert at some width.
What to check on a scale. The extremes — 320px and 1920px.
Why min() reads backwards. "The smaller of these" is a ceiling, so it behaves
like max-width.
What min(100% - 2rem, 70rem) replaces. A width, a max-width and a padding.
Why ch is already fluid. It is relative to the font size.
Why not to clamp line-height. It is a ratio and already scales.
What clamp() cannot do. Rearrange a layout, or hold steady and then jump.
Practice
- Set
h1 { font-size: clamp(1.75rem, 5vw, 3rem) }and resize from 320px to 1600px. Read the computed size in devtools at each end. - Calculate by hand what
5vwis at 320, 800 and 1600, and check against devtools. - Set
h1 { font-size: 5vw }, then change your browser's default font size to 24px. Note that nothing happens. - Do the same with
clamp(1.75rem, 1.25rem + 2.5vw, 3rem)and compare. - Use browser zoom, then text-only zoom if your browser has it, on both versions.
- Work out the interpolation for 16px at 360px and 22px at 1200px, and verify both ends in devtools.
- Build the six-step scale and check at 320px and 1920px that no two steps cross.
- Deliberately give two steps very different slopes and find the width where they invert.
- Replace a section's padding media queries with one
clamp(). - Convert a container from
width: 100%plusmax-width: 65chtowidth: min(100%, 65ch). - Use
width: min(100% - 2rem, 70rem)and confirm the gutter survives at 320px. - Clamp a
line-heightand explain why it made no useful difference. - Clamp a border width and look at it on a high-density screen.
- Find a heading on a real site that is too big on a phone, and write the
clamp()that would fix it.
Official documentation
- MDN — clamp() — The definition, including the
max(min())equivalence and what happens with invalid bounds. - MDN — min() and max() — Why they read the opposite way round to their names.
- W3C — Understanding WCAG 1.4.4 Resize Text — The requirement that pure
vwtype fails. - Utopia — fluid type scale calculator — Not official, but the standard tool for generating a scale. Generates the
clamp()values from two sizes at two widths.
Next: the query that knows how much space the component has.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship