RizTech Academy logo
RizTech Academy
Responsive DesignLesson 4 of 725 min

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, 5vw is 16px — below the 1.75rem (28px) floor, so it is 28px.
  • At 800px, 5vw is 40px — inside the range, so it is 40px.
  • At 1600px, 5vw is 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

  1. Set h1 { font-size: clamp(1.75rem, 5vw, 3rem) } and resize from 320px to 1600px. Read the computed size in devtools at each end.
  2. Calculate by hand what 5vw is at 320, 800 and 1600, and check against devtools.
  3. Set h1 { font-size: 5vw }, then change your browser's default font size to 24px. Note that nothing happens.
  4. Do the same with clamp(1.75rem, 1.25rem + 2.5vw, 3rem) and compare.
  5. Use browser zoom, then text-only zoom if your browser has it, on both versions.
  6. Work out the interpolation for 16px at 360px and 22px at 1200px, and verify both ends in devtools.
  7. Build the six-step scale and check at 320px and 1920px that no two steps cross.
  8. Deliberately give two steps very different slopes and find the width where they invert.
  9. Replace a section's padding media queries with one clamp().
  10. Convert a container from width: 100% plus max-width: 65ch to width: min(100%, 65ch).
  11. Use width: min(100% - 2rem, 70rem) and confirm the gutter survives at 320px.
  12. Clamp a line-height and explain why it made no useful difference.
  13. Clamp a border width and look at it on a high-density screen.
  14. Find a heading on a real site that is too big on a phone, and write the clamp() that would fix it.

Official documentation

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