Implementing a Fluid Type Scale with clamp() for Responsive Typography
A fluid type scale replaces a stack of media-query font-size overrides with a single clamp() expression per step, so text grows smoothly with the viewport instead of jumping at breakpoints. This guide is part of the Type Scale & Modular Grids guide within the broader Typography Fundamentals & System Architecture blueprint, and it covers the exact min/preferred/max interpolation math, the defensive bounds that keep zoom accessible, and how to stop the web-font swap from shifting layout.
Problem Statement
Viewport-linked sizing with raw vw units is tempting but breaks in two specific ways. First, an unbounded vw value becomes illegibly small on narrow phones and absurdly large on wide monitors, and it defeats browser zoom because vw ignores the user's zoom setting — a WCAG 1.4.4 failure. Second, when you do bound it with clamp() but compute the slope sloppily, the preferred value can fall outside the min/max at the very widths you cared about, producing a discontinuity that looks like a jump. Layered on top, the web-font swap resizes line boxes if the fallback metrics differ, adding Cumulative Layout Shift. This page gives you the interpolation formula that guarantees a continuous curve, the bounds that keep zoom working, and the fallback-metric fix for the swap.
The curve below shows how clamp() behaves: the preferred (viewport-linked) value rises linearly until it is bounded by the flat minimum at narrow viewports and the flat maximum at wide ones.
Prerequisites
A few things should be settled before you write the first clamp():
- A root font size you can rely on. All the math below assumes the default
1rem = 16px. Do not set a percentagefont-sizeonhtml, because that breaks the px-to-rem conversion the slope depends on and re-introduces zoom problems. - The min/max sizes from your modular scale. Each step's floor and ceiling should come from a ratio, not guesswork. Derive them with modular scale and CSS custom properties so the fluid steps stay harmonically related.
- An evergreen browser baseline.
clamp(),min(), andmax()are supported in Chrome/Edge 79+, Firefox 75+, and Safari 13.1+. Below that you need a static fallback, shown in the variant. - Fallback-font metrics for any web font you load, so you can neutralize the swap shift (covered in the variant and verification).
Implementation
The fluid formula is linear interpolation between two anchor points: a minimum font size at a minimum viewport width, and a maximum font size at a maximum viewport width. Compute the slope and intercept once per step, in rem, then assemble the clamp().
- slope (
m) =(max-font-rem − min-font-rem) / (max-vw-rem − min-vw-rem) - intercept (
b) =min-font-rem − (m × min-vw-rem) - expression:
clamp(min-font-rem, b-rem + m × 100vw, max-font-rem)
For example, scaling an H1 from 1rem at 320px to 2.5rem at 1440px:
- Convert widths to rem:
320 / 16 = 20rem,1440 / 16 = 90rem - slope =
(2.5 − 1) / (90 − 20)=1.5 / 70≈0.02143 - intercept =
1 − (0.02143 × 20)=0.5714rem - preferred term =
0.5714rem + (0.02143 × 100vw)=0.5714rem + 2.143vw - result:
clamp(1rem, 0.5714rem + 2.143vw, 2.5rem)
Fluid scale as CSS custom properties
:root {
/* H1: 1rem @ 320px -> 2.5rem @ 1440px
slope = (2.5 - 1) / (90 - 20) = 0.02143 ; intercept = 0.5714rem */
--fluid-h1: clamp(1rem, 0.5714rem + 2.143vw, 2.5rem);
/* Body: 0.875rem @ 320px -> 1.25rem @ 1440px
slope = (1.25 - 0.875) / 70 = 0.005357 ; intercept = 0.7679rem */
--fluid-body: clamp(0.875rem, 0.7679rem + 0.5357vw, 1.25rem);
}
.fluid-h1 {
font-size: var(--fluid-h1);
line-height: 1.2; /* unitless: scales with the computed size */
}
.fluid-body {
font-size: var(--fluid-body);
line-height: 1.5;
}
Why each part matters:
- The intercept carries a
remunit, the slope term carriesvw. Mixing them insideclamp()is exactly what makes the line continuous: at320pxthe preferred term evaluates to the min, at1440pxto the max, so there is no step at either anchor. - Storing each step as a custom property keeps the math in one place and lets components reference
var(--fluid-h1)without repeating the expression — the same single-source-of-truth discipline used for a system font stack. - Unitless
line-heightis mandatory here. A fixedpx/remline-height stays constant whilefont-sizescales, so lines either crowd or gap as the viewport changes;1.2and1.5scale proportionally with the computed size and preserve vertical rhythm. - The
vwcoefficient stays modest (2.143vw). Because it is bounded byclamp(), it never runs away — but keeping the slope gentle also means the text does not lurch during a window resize.
Fallback & Zoom-Safe Variant
The clamp expression above degrades to nothing on a browser that lacks clamp(), and on its own vw can still defeat zoom if your bounds are wrong. The defensive pattern is a static fallback font-size first (so unsupported engines get a sane fixed size), then the fluid value, then the web-font integration guarded by a feature query so legacy browsers fall back to static weights rather than broken variation settings.
Static fallback, fluid override, and guarded variable-font sync
.fluid-h1 {
font-size: 2rem; /* 1. static fallback for no-clamp engines */
font-size: var(--fluid-h1); /* 2. fluid value where clamp() is supported */
line-height: 1.2;
}
/* 3. Variable-font axes only where the engine proves it understands them. */
@supports (font-variation-settings: 'wght' 400) {
.fluid-h1 {
font-weight: 700; /* prefer font-weight over raw variation-settings */
font-optical-sizing: auto; /* let opsz track the fluid font-size */
}
}
The defensive points:
- The double
font-sizedeclaration is a deliberate cascade fallback: a browser that cannot parseclamp()ignores line 2 and keeps the static2rem; a modern browser parses both and the later one wins. - Zoom safety comes from the
clamp()minimum, not from this block. Because the floor is expressed inrem, a user zooming to 200% scales the floor too, so text never becomes unreadably small the way an unboundedvwvalue would. Always keep arem-based minimum for this reason. font-optical-sizing: autoties theopszaxis to the fluid computed size, so the headline is optically tuned at every viewport without extra rules.
To stop the web-font swap from shifting these fluid lines, override the fallback face metrics so its line box matches the loaded font at any size:
Fallback metric overrides to neutralize swap-induced CLS
@font-face {
font-family: 'Body Fallback';
src: local('Arial');
size-adjust: 105%;
ascent-override: 92%;
descent-override: 24%;
line-gap-override: 0%;
}
:root { font-family: 'Inter', 'Body Fallback', sans-serif; }
Because the overrides make the fallback's vertical metrics identical to the loaded font, the line box height is the same before and after the swap, so the fluid text does not reflow when the font arrives. Pair this with font-display: swap and a <link rel="preload" as="font" type="font/woff2" crossorigin> on the critical weight.
Verification
Confirm the curve is continuous, zoom-safe, and shift-free:
- Continuity at the anchors. In DevTools, set the viewport to exactly
320pxand read the computedfont-sizein Elements → Computed — it should equal the min (e.g.16pxfor the H1). Set it to1440px; it should equal the max (40px). A mismatch means the slope/intercept are off. - No mid-range jump. Use the device-toolbar width slider and drag through the range; the computed
font-sizeshould change smoothly with no discontinuity. For pixel-precision, step the width by 1px around your anchors. - Zoom accessibility. Zoom the page to 200% and confirm body text stays at or above ~12px effective and nothing truncates. Lighthouse's Document uses legible font sizes audit will flag sub-12px outputs.
- Swap stability. Enable Rendering → Layout Shift Regions, reload with cache disabled, and confirm the fluid text block does not flash a shift region when the web font swaps in. Total CLS target is < 0.1; gate it in
lighthouse-ci.
Common Pitfalls
| Pitfall | Symptom | Resolution |
|---|---|---|
| Slope computed in px instead of rem | font-size overshoots or undershoots the anchor widths |
Convert both viewport widths to rem (divide px by 16) before computing slope and intercept. |
Fixed px/rem line-height with a fluid font-size |
Lines crowd or gap as the viewport changes | Use unitless line-height (e.g. 1.2, 1.5) so it scales with computed size. |
Unbounded vw with no clamp() floor |
Zoom at 200% leaves text tiny; WCAG 1.4.4 failure | Always wrap in clamp() with a rem-based minimum so zoom scales the floor. |
em scale steps in nested components |
Sizes compound unpredictably under a custom parent font-size |
Use rem for scale steps so each step is relative to the root, not the parent. |
No size-adjust on the fallback face |
FOUT/FOIT layout shift on slow connections | Add size-adjust and ascent/descent overrides to the fallback @font-face; preload the critical font. |
FAQ
Why does my clamp() font size jump at specific viewport widths?
The slope and intercept were computed inconsistently — usually mixing px and rem, or rounding too aggressively. Recompute both anchor widths in rem (px ÷ 16), keep four or five significant figures in the slope, and verify in DevTools that the computed size at the min width equals your min and at the max width equals your max. If both anchors match exactly, the line between them is continuous and the apparent jump disappears.
How do I prevent CLS when using fluid typography with web fonts?
Pair clamp() with metric overrides on the fallback @font-face (size-adjust, ascent-override, descent-override) tuned to the loaded font, plus font-display: swap, and preload the critical weight. The overrides make the fallback's line box height identical to the web font's, so the swap does not resize lines — independent of how the fluid font-size is changing with the viewport.
Can clamp() replace media queries entirely for typography?
No. clamp() handles continuous font-size (and other length) scaling, but it cannot reflow layout, switch a grid's column count, or apply discrete art-directed changes. Use clamp() for fluid font sizes and spacing, and reserve media or container queries for structural layout changes that are genuinely discontinuous.
Related
- Type Scale & Modular Grids — the parent guide for this technique.
- Modular scale with CSS custom properties — derive the min/max sizes that feed each clamp().
- Line Height & Vertical Rhythm — keep unitless line-height in sync as sizes scale.