HTML & CSS

Fluid typography with clamp(): sizes that scale with the viewport without breakpoints

How the CSS clamp() function works, how to derive the linear formula behind fluid font sizes by hand, why rem must stay in the expression for accessibility, and a complete type scale you can paste in.

By the TechTutorZone editorial team · Published · 6 min read (1,153 words)

Headings that are the right size on a phone are too small on a desktop, and the other way round. The old fix was a media query per breakpoint, each with its own font size, and a visible jump at every one. clamp() replaces that with a single expression that grows smoothly between a minimum and a maximum. This tutorial derives the formula so you can write it by hand, shows a full type scale, and covers the accessibility trap that most generated snippets ignore. If you only want the number, the fluid type calculator does the arithmetic; read on to understand what it gives you.

What clamp() does

clamp(MIN, PREFERRED, MAX) resolves to PREFERRED, unless that is below MIN (then MIN) or above MAX (then MAX). It is shorthand for max(MIN, min(PREFERRED, MAX)). The useful part is that PREFERRED can mix units, because the browser only needs to resolve it to a length when it lays the page out:

h1 {
  font-size: clamp(1.75rem, 0.95rem + 2.5vw, 2.75rem);
}

At a 400px-wide viewport, 2.5vw is 10px, so the preferred value is 0.95rem + 10px = 15.2 + 10 = 25.2px; that is below the 28px minimum, so the heading is 28px. At 1200px, 2.5vw is 30px and the preferred value is 45.2px, above the 44px maximum, so the heading is 44px. In between it slides linearly. No media query, no jumps.

Deriving the preferred value

You decide four numbers: the viewport width where scaling should start and stop, and the font size at each end. Say 18px at 360px wide and 28px at 1200px wide. The preferred value is a straight line through those two points, size = slope × viewport + intercept:

slope     = (28 - 18) / (1200 - 360) = 10 / 840 = 0.0119 px per px of viewport
intercept = 18 - slope * 360         = 18 - 4.286 = 13.714 px

In CSS, "px of viewport" is expressed with vw, where 1vw is 1% of the viewport width. A slope of 0.0119 px per px is 1.19 px per vw, so the vw coefficient is slope × 100. Convert the intercept and the bounds to rem by dividing by 16 (the default root font size), and the result is:

font-size: clamp(1.125rem, 0.857rem + 1.19vw, 1.75rem);

Check it: at 360px, 1.19vw = 4.28px, plus 13.71px = 18px. At 1200px, 1.19vw = 14.28px, plus 13.71px = 28px. The clamp bounds catch everything outside that range.

Why the rem term matters

It is tempting to simplify to font-size: 2.3vw and skip the intercept. Two things go wrong. First, the size no longer respects the reader's chosen default font size: a user who set 20px in their browser settings gets exactly the same text as everyone else, because vw knows nothing about rem. Second, and this is a WCAG failure, browser zoom stops working. When a user zooms to 200%, the browser makes every CSS pixel twice as big, which means the viewport is now half as many CSS pixels wide, which means 2.3vw is half as many CSS pixels, which, times two for the zoom, lands exactly where it started. Text set purely in vw cannot be zoomed at all.

WCAG success criterion 1.4.4 requires text to be resizable to 200%. The rem term in the preferred value is what makes that possible: it zooms, the vw term does not, and as long as the rem term carries enough of the value the total grows. There is a practical test: simulate 500% zoom (the usual ceiling browsers offer) and check that the on-screen text is at least twice its unzoomed size at every viewport width in your range. The calculator runs that test and reports the worst case. The rule of thumb is that a vw coefficient above about 2 with a small rem term is where failures begin, which happens when the two sizes you chose are far apart over a narrow viewport range.

A complete scale

A sensible scale has a fixed ratio between steps at each end. This one uses roughly 1.2 at the small end and 1.25 at the large end, between 360px and 1200px, and puts the values in custom properties so components can use them:

:root {
  --step--1: clamp(0.83rem, 0.79rem + 0.18vw, 0.94rem);
  --step-0:  clamp(1.00rem, 0.94rem + 0.28vw, 1.17rem);
  --step-1:  clamp(1.20rem, 1.09rem + 0.49vw, 1.46rem);
  --step-2:  clamp(1.44rem, 1.26rem + 0.80vw, 1.83rem);
  --step-3:  clamp(1.73rem, 1.45rem + 1.24vw, 2.29rem);
  --step-4:  clamp(2.07rem, 1.65rem + 1.87vw, 2.86rem);
}
body  { font-size: var(--step-0); line-height: 1.6; }
h1    { font-size: var(--step-4); line-height: 1.15; }
h2    { font-size: var(--step-3); line-height: 1.2; }
h3    { font-size: var(--step-2); line-height: 1.3; }
small { font-size: var(--step--1); }

Body text barely moves (16px to 18.7px); headings move a lot more. That matches how print designers scale type: body copy is sized for reading distance, which does not change with screen size, while display sizes are proportioned to the page.

clamp() beyond font sizes

The same expression works for any length. Fluid spacing keeps vertical rhythm proportional to the type:

.section { padding-block: clamp(2rem, 1rem + 4vw, 5rem); }
.wrap    { width: min(100% - 2rem, 70rem); margin-inline: auto; }

The second line is a related trick: min() picks the smaller of "full width minus a 1rem gutter each side" and a 70rem cap, which replaces the usual max-width plus padding pair.

Pitfalls

  • Units inside calc. The preferred value is an implicit calc(), so 0.857rem + 1.19vw needs spaces around the +. 0.857rem+1.19vw is invalid and the whole declaration is dropped.
  • vw and scrollbars. On desktop, 100vw includes the vertical scrollbar's width, so content sized to 100vw can overflow by about 15px. For font sizes this does not matter; for widths, use percentages.
  • Line length. Fluid type on a fixed-width container changes the characters per line. Keep measure between about 45 and 75 characters by giving the text container a max-width in ch (for example max-width: 65ch), which scales with the font size automatically.
  • Over-fluid headings. A heading that goes from 24px to 72px looks dramatic in a demo and is unreadable on a wide monitor with a short title. Keep the max to what the longest real heading can bear.

Exercise

Take 20px at 320px and 40px at 1280px and derive the clamp() by hand using the steps above. Check your answer in the calculator. Then change the small size to 12px and watch the WCAG check; find the smallest "size at smallest" value that still passes.

Summary

  • clamp(min, preferred, max); the preferred value is a line: intercept + slope×100 vw.
  • Keep a rem term in the preferred value so the text respects user font settings and can be zoomed (WCAG 1.4.4).
  • Scale headings a lot and body text a little; cap line length in ch.
  • The same function sizes spacing and widths.

Next: custom properties and a dark mode that respects system settings.