Fluid Type Systems: The Parts the Calculators Don't Generate
Utopia and the clamp() generators solve the interpolation. What they don't give you: a ratio that scales with the viewport, vertical rhythm that survives fluid sizing, and the viewport-height unit that quietly reflows on every scroll.
Fluid Type Systems: The Parts the Calculators Don’t Generate
If you want the clamp() interpolation math, it is well covered and you should read the people who covered it. Utopia is the canonical tool and the canonical writing on the subject. Smashing Magazine has a thorough treatment. Every clamp calculator explains slope and intercept on its own page.
This article is about what those resources leave you to work out yourself, which is most of what turns a set of clamp() values into a system that holds together.
Three things:
- A modular ratio that interpolates rather than staying fixed
- Vertical rhythm that survives fluid sizing — which fixed
remmargins cannot deliver - The viewport-height unit that reflows during scroll, and why it should not be in your layout
Reference: the interpolation, briefly
For completeness, so you do not need a second tab.
slope = (maxSize − minSize) / (maxWidth − minWidth)
intercept = minSize − slope × minWidth
font-size: clamp(minSize, intercept + (slope × 100)vw, maxSize);
Worked, 2rem at 320px to 4rem at 1440px, 16px root:
slope = (64 − 32) / (1440 − 320) = 0.02857
intercept = 32 − (0.02857 × 320) = 22.86px = 1.4286rem
h1 { font-size: clamp(2rem, 1.4286rem + 2.857vw, 4rem); }
Use Utopia rather than doing this by hand. The value of knowing the derivation is being able to tell when a generator has produced something wrong — see the accessibility section below — not doing the arithmetic yourself.
Prior art on the accessibility problem — read this before you ship fluid type
This is established, it is not my finding, and the people who worked it out deserve the attribution.
Adrian Roselli has been warning about untested fluid type since 2019 and is the origin of essentially every subsequent warning on the subject. Smashing Magazine’s 2023 article collects the arguments. Trys Mudford added explicit WCAG warnings to Utopia and wrote up why.
Mudford’s statement of the mechanism is the clearest I have read, and worth understanding precisely rather than in summary:
Browser zoom does not change the viewport width in CSS pixels. Zoom to 500% and 1vw still equals 1% of the browser width. So in a clamp() whose preferred value is rem + vw, only the rem half responds to zoom. The consequence Mudford identifies: Chrome’s 500% zoom limit might deliver only around a 150% text-size increase, against WCAG 1.4.4’s requirement of 200%.
The practical implication is that “I used rem in my clamp, so I’m compliant” is not true. It depends on the proportion. A heuristic I use — check the rem term contributes at least half the computed size at your maximum viewport — is a rough operationalisation of Roselli’s point, not a substitute for the test:
- Set your browser’s default font size to 24px.
- Reload. Text should be visibly larger. If nothing moves, the scale ignores user preference.
- Separately, zoom to 200% and confirm text reaches roughly double its original size.
Step 2 and step 3 are different tests. Passing one does not imply the other. Several published generators produce output that fails step 3 while looking correct.
Everything above is context. The rest is the part the tools do not generate.
1. Interpolate the ratio, not just the base
A modular scale multiplies each step by a fixed ratio — 1.2 minor third, 1.25 major third, 1.333 perfect fourth, 1.5 perfect fifth. Every generator I have used treats that ratio as a constant and scales only the base size.
That is the wrong model, and you can see why on a phone. A 1.5 ratio that reads as commanding on a 1440px display is oppressive at 375px: the top step overflows its container, and the compounding gap between h1 and body text stops being a hierarchy and starts being a disconnect. Narrow viewports want a tight scale. Wide viewports can carry a dramatic one.
So interpolate the ratio itself:
:root {
/* The ratio is fluid: 1.2 at 320px → 1.414 at 1440px */
--ratio: clamp(1.2, 1.14 + 0.19vw, 1.414);
/* Base step carries the user's root preference into the whole scale */
--step-0: clamp(1rem, 0.96rem + 0.18vw, 1.125rem);
--step--2: calc(var(--step--1) / var(--ratio));
--step--1: calc(var(--step-0) / var(--ratio));
--step-1: calc(var(--step-0) * var(--ratio));
--step-2: calc(var(--step-1) * var(--ratio));
--step-3: calc(var(--step-2) * var(--ratio));
--step-4: calc(var(--step-3) * var(--ratio));
}
h1 { font-size: var(--step-4); }
h2 { font-size: var(--step-3); }
h3 { font-size: var(--step-2); }
h4 { font-size: var(--step-1); }
p { font-size: var(--step-0); }
small { font-size: var(--step--1); }
What this produces:
| Viewport | --ratio |
--step-0 |
--step-4 (h1) |
|---|---|---|---|
| 320px | 1.2 | ~1rem | ~2.07rem |
| 768px | ~1.28 | ~1.05rem | ~2.83rem |
| 1440px | 1.414 | 1.125rem | ~4.5rem |
The scale compresses where space is scarce and expands where it is not, and every step stays proportionate to its neighbours at every width in between — not just at the two anchors a generator optimises for.
Two properties worth noting.
Because every step derives from --step-0 by multiplication, the user’s root font-size preference propagates through the entire hierarchy from one correctly-built base. Fix accessibility once, inherit it everywhere.
And because the ratio is itself a clamp(), it is bounded. It cannot run away at extreme viewports the way an unbounded vw-driven ratio would.
The constraint: keep the ratio range narrow. 1.2→1.414 is a comfortable span. Push it to 1.1→1.8 and the compounding across five steps produces a scale that feels like two different designs at the extremes.
2. 1lh — the unit that makes rhythm survive fluid sizing
Vertical rhythm is where fluid type systems quietly fall apart, and the failure is structural rather than aesthetic.
Set margin-block-end: 1.5rem on paragraphs. At your minimum viewport, body text is 1rem with a 1.6 line-height — so the line box is 1.6rem and your margin is 0.94 lines. At your maximum viewport, body text is 1.125rem, the line box is 1.8rem, and the same margin is now 0.83 lines. The spacing has drifted relative to the text it is spacing, continuously, across the whole viewport range. It looks slightly wrong everywhere except the one width you designed at.
1lh equals one line-height of the current element:
p, ul, ol, blockquote, figure, pre {
margin-block-end: 1lh;
}
h2 {
margin-block-start: 2lh;
margin-block-end: 0.5lh;
}
The margin is now defined in terms of the element’s own line box. As font-size interpolates, the line box interpolates, and the margin interpolates with it. Rhythm holds at every viewport width automatically, because it is expressed as a relationship rather than a measurement.
This is the single highest-value substitution in a fluid system, and it is almost entirely absent from the fluid-type writing I can find — which is odd, since fluid sizing is precisely the context where fixed rhythm units break.
Related units in the same family:
| Unit | Equals |
|---|---|
1lh |
One line-height of the current element |
1rlh |
One line-height of the root element |
1cap |
Cap height of the current font |
1ex |
x-height of the current font |
1ch |
Width of the 0 glyph |
1cap is the correct unit for optically aligning an icon to adjacent text — cap height, not font size, is what the eye aligns to:
.inline-icon {
block-size: 1cap;
inline-size: auto;
}
3. dvh reflows during scroll — keep it out of layout
Four viewport-height units, and the difference matters more than the naming suggests:
| Unit | Definition | Behaviour on mobile |
|---|---|---|
vh |
Largest viewport | Content hides under browser chrome |
lvh |
Largest viewport, explicit | Same as vh |
svh |
Smallest viewport | Always fully visible; never jumps |
dvh |
Dynamic | Updates as chrome shows and hides |
dvh is the one that looks most correct and behaves worst.
On mobile, the URL bar collapses as you scroll down and reappears as you scroll up. dvh tracks that. So an element sized min-block-size: 100dvh changes size mid-scroll — which means layout, which means paint, on the main thread, triggered by scrolling.
You have reproduced the scroll-jank problem in CSS, without writing a scroll listener.
/* Reflows during scroll on mobile */
.hero { min-block-size: 100dvh; }
/* Stable. Content always fits. No reflow. */
.hero { min-block-size: 100svh; }
The rule: svh for anything whose size affects layout. Reserve dvh for cases where tracking the chrome is genuinely the requirement — a fixed-position overlay that must always meet the bottom edge of the visible area, for instance — and confirm it is not triggering layout on anything else.
Verify it: DevTools → Performance, record while scrolling on a mobile emulation profile. dvh on a layout-affecting property produces repeating Layout entries synchronised with chrome transitions. svh produces none.
Assembling the system
The three pieces together, plus the parts the generators do give you:
:root {
/* Fluid ratio and base — see section 1 */
--ratio: clamp(1.2, 1.14 + 0.19vw, 1.414);
--step-0: clamp(1rem, 0.96rem + 0.18vw, 1.125rem);
--step-1: calc(var(--step-0) * var(--ratio));
--step-2: calc(var(--step-1) * var(--ratio));
--step-3: calc(var(--step-2) * var(--ratio));
--step-4: calc(var(--step-3) * var(--ratio));
/* Space scales more conservatively than type: ~1.25× against ~2×.
Matching the rates gives you desktop gaps on a phone. */
--space-s: clamp(1rem, 0.91rem + 0.43vw, 1.25rem);
--space-m: clamp(1.5rem, 1.37rem + 0.64vw, 1.88rem);
--space-l: clamp(2rem, 1.82rem + 0.86vw, 2.5rem);
}
body { font-size: var(--step-0); line-height: 1.6; }
/* Rhythm in line-boxes, not fixed lengths — see section 2 */
p, ul, ol, blockquote { margin-block-end: 1lh; }
h2 { margin-block-start: 2lh; margin-block-end: 0.5lh; }
/* Optical corrections large type needs and small type doesn't */
h1, h2, h3 {
line-height: clamp(1.05, 1.4 - 0.4vw, 1.25);
letter-spacing: clamp(-0.03em, -0.01em - 0.02vw, 0em);
text-wrap: balance;
}
p {
text-wrap: pretty;
/* Non-negotiable. Fluid width without a measure cap produces
200-character lines on a wide monitor — worse than any
breakpoint system would have given you. */
max-inline-size: 68ch;
}
/* svh, not dvh — see section 3 */
.section-full { min-block-size: 100svh; }
On the wrapping properties, as of 25 July 2026: text-wrap: balance is capped at six lines in Chromium and ten in Firefox, beyond which it silently falls back — so it is unusable for body copy regardless of preference. text-wrap: pretty is in Chrome/Edge 117+ and Safari 26+, not yet Firefox. Both degrade to normal wrapping with no breakage; neither needs a fallback.
Component scale: cqi, not vw
One correction to the above for components that appear in more than one context. vw measures the viewport, so a card in a full-bleed hero and the same card in a 300px sidebar both scale to the window — and the sidebar card gets hero-sized type.
.card-region {
container-type: inline-size;
}
.card__title {
font-size: clamp(1.125rem, 0.9rem + 2cqi, 1.75rem);
}
cqi is 1% of the container’s inline size. Prefer it over cqw — it is writing-mode independent, so it stays correct in vertical and RTL contexts.
The failure mode to know: without container-type on an ancestor, container units resolve against the small viewport. Silently. No error, no warning, just wrong values that look plausible. If a cqi value is behaving like a vw value, that is why.
Frequently asked questions
Should I calculate clamp() values by hand?
No — use Utopia or a calculator. Understand the derivation so you can recognise bad output, particularly on the accessibility constraint, but do not do the arithmetic manually.
Does fluid typography fail WCAG?
It can. Browser zoom does not change viewport width in CSS pixels, so the vw component of a clamp() does not respond to zoom — only the rem component does. As Trys Mudford documents, Chrome’s 500% zoom limit may then produce only around a 150% text increase, against WCAG 1.4.4’s 200% requirement. Test with both a raised browser default font size and 200% zoom; they are separate tests.
Why interpolate the modular ratio?
A ratio that reads as commanding at 1440px is oppressive at 375px, because the ratio compounds across every step. Scaling the ratio itself — clamp(1.2, …, 1.414) — compresses the scale where space is scarce and expands it where it is not.
What does 1lh do that rem cannot?
1lh is one line-height of the current element, so a margin expressed in it interpolates automatically as font size interpolates. A fixed rem margin drifts relative to the text it spaces across the viewport range — correct at one width, slightly wrong everywhere else.
Should I use dvh?
Not for anything that affects layout. dvh updates as mobile browser chrome shows and hides, so it triggers layout during scroll. Use svh — the smallest viewport — which is stable and always fully visible.
Why is cqi returning viewport-sized values?
An ancestor is missing container-type: inline-size. Without a container, container units fall back to the small viewport with no error.
Should spacing scale at the same rate as type? No. Type commonly scales around 2× from minimum to maximum; space around 1.25×. Matching them produces layouts that read as loose on desktop or cramped on mobile.
Do I still need breakpoints? Yes, for arrangement. Fluid scaling handles size continuously. Changing column counts or reordering content is a discrete change and needs a query — preferably a container query.
Sources and prior art
This article deliberately does not re-teach the interpolation math or re-derive the accessibility argument, both of which are covered better elsewhere:
- Utopia — Trys Mudford and James Gilyead. The canonical fluid-scale tool and writing.
- Adrian Roselli, Responsive Type and Zoom — the origin of the fluid-type accessibility warning.
- Trys Mudford, Utopia WCAG warnings — the clearest statement of the zoom/viewport-unit mechanism.
- Smashing Magazine, Addressing Accessibility Concerns With Using Fluid Type — Max Barvian’s collection of the arguments.
- MDN:
clamp(),text-wrap-style, container queries, viewport units - W3C WCAG 2.2: Understanding 1.4.4 Resize Text
Claims last verified 25 July 2026. See our editorial standards.