
How to Build Fluid Typography That Scales Across Every Screen
Look at the stylesheet of almost any site built a few years ago, and you'll find something like this: a headline at 2rem, then 2.5rem at 768 pixels, 3rem at 1024, and 3.5rem at 1440. Each breakpoint is a sudden jump. Resize the browser slowly and the text lurches from one size to the next, and on the screens in between, the sizes are only ever approximately right.
Fluid typography replaces those jumps with a smooth line. Text grows continuously from a minimum size on small screens to a maximum size on large ones, and it's exactly right at every width in between. With clamp(), the whole thing fits in a single declaration per step of your type scale.
In this guide, I'll show you the maths behind fluid type (it's simpler than it looks), how to build a complete fluid type scale, how to make it respond to containers instead of the viewport, and the accessibility rule that too many fluid type implementations break.
The Basic Building Block: clamp()
clamp() takes three values: a minimum, a preferred value, and a maximum.
h1 {
font-size: clamp(2rem, 1.5rem + 2.5vw, 3.5rem);
}
The browser computes the preferred value, 1.5rem + 2.5vw, and then clamps it between 2rem and 3.5rem. On narrow screens, the headline sits at 2rem. On wide screens, it stops at 3.5rem. In between, it grows linearly with the viewport width.
The key part is the preferred value. It needs a viewport-relative unit like vw so it grows, and it should also include a fixed part, like rem, for reasons we'll come to in the accessibility section.
The Maths: Turning Two Sizes into a Formula
Guessing the middle value until it looks right is how most people start, but you can calculate it exactly. You decide two points:
- At a minimum viewport width, the text should be the minimum font size.
- At a maximum viewport width, the text should be the maximum font size.
The preferred value is simply the straight line passing through those two points. A straight line has a slope and an intercept.
Let's work an example. Body text should be 1rem (16px) at a 20rem (320px) viewport and 1.25rem (20px) at an 80rem (1280px) viewport.
- Slope: the change in font size divided by the change in viewport width.
(1.25 - 1) / (80 - 20) = 0.25 / 60 = 0.004167. - Convert the slope to
vw: 1vw is 1% of the viewport width, so multiply by 100.0.004167 * 100 = 0.4167vw. - Intercept: the font size the line would have at a zero-width viewport.
1 - 20 * 0.004167 = 0.9167rem.
Put it together:
body {
font-size: clamp(1rem, 0.9167rem + 0.4167vw, 1.25rem);
}
Check it. At 320px wide, 0.4167vw is about 1.33px, and 0.9167rem is about 14.67px, which sums to 16px. At 1280px, 0.4167vw is about 5.33px, plus 14.67px is 20px. The line hits both targets exactly.
A Reusable Formula
Writing it out generally:
slope = (maxSize - minSize) / (maxViewport - minViewport)
intercept = minSize - minViewport * slope
preferred = intercept(rem) + slope * 100(vw)
Keep all sizes in rem and all viewport widths in rem too, and the units cancel cleanly.
Letting CSS Do the Maths
If you'd rather not calculate by hand, you can express the formula in CSS with custom properties. Modern CSS lets you divide lengths inside calc() in some browsers, but for broad support it's safest to keep the inputs unitless and multiply them into units at the end:
.fluid {
--min-size: 1; /* rem */
--max-size: 1.25; /* rem */
--min-vw: 20; /* rem */
--max-vw: 80; /* rem */
--slope: calc(
(var(--max-size) - var(--min-size)) / (var(--max-vw) - var(--min-vw))
);
--intercept: calc(var(--min-size) - var(--min-vw) * var(--slope));
font-size: clamp(
calc(var(--min-size) * 1rem),
calc(var(--intercept) * 1rem + var(--slope) * 100vw),
calc(var(--max-size) * 1rem)
);
}
This works in every modern browser because unitless numbers can be divided freely. Override the four inputs on any element to get a different fluid size.
Or with a Sass Function
If you use Sass, a function keeps things tidy and outputs plain clamp() values:
@use "sass:math";
@function fluid($min-size, $max-size, $min-vw: 20, $max-vw: 80) {
$slope: math.div($max-size - $min-size, $max-vw - $min-vw);
$intercept: $min-size - $min-vw * $slope;
@return clamp(
#{$min-size}rem,
#{math.div(math.round($intercept * 10000), 10000)}rem +
#{math.div(math.round($slope * 1000000), 10000)}vw,
#{$max-size}rem
);
}
h2 {
font-size: fluid(1.5, 2.25);
}
Building a Fluid Type Scale
A single fluid size is nice, but a real design system needs a scale: small text, body, and several heading levels that keep a consistent relationship.
A popular approach is to define a modular scale at both ends. On small screens, use a gentler ratio, such as 1.2 (the "minor third"). On large screens, use a more dramatic one, such as 1.333 (the "perfect fourth"). Then make each step fluid between its small-screen and large-screen size.
Starting from a body size of 16px at 320px and 20px at 1280px:
| Step | Small screen (ratio 1.2) | Large screen (ratio 1.333) |
|---|---|---|
| -1 | 13.33px | 15.00px |
| 0 | 16.00px | 20.00px |
| 1 | 19.20px | 26.66px |
| 2 | 23.04px | 35.54px |
| 3 | 27.65px | 47.37px |
| 4 | 33.18px | 63.15px |
Applying the formula to each row gives a set of tokens:
:root {
--step--1: clamp(0.8333rem, 0.7985rem + 0.174vw, 0.9377rem);
--step-0: clamp(1rem, 0.9167rem + 0.4167vw, 1.25rem);
--step-1: clamp(1.2rem, 1.0446rem + 0.7771vw, 1.6663rem);
--step-2: clamp(1.44rem, 1.1796rem + 1.3019vw, 2.2211rem);
--step-3: clamp(1.728rem, 1.3171rem + 2.0546vw, 2.9607rem);
--step-4: clamp(2.0736rem, 1.4492rem + 3.1218vw, 3.9467rem);
}
body {
font-size: var(--step-0);
}
small {
font-size: var(--step--1);
}
h4 {
font-size: var(--step-1);
}
h3 {
font-size: var(--step-2);
}
h2 {
font-size: var(--step-3);
}
h1 {
font-size: var(--step-4);
}
Notice what happens: on a phone, the jump from body text to h1 is about 2x. On a desktop, it's about 3x. Headings get proportionally bigger as there's more room for them, while body text only grows slightly. That's exactly how an experienced designer would size type by hand at each breakpoint, but now it's continuous.
You don't have to compute these yourself. Tools like Utopia generate fluid type scales and spacing from a handful of inputs. Understanding the maths just means you can adjust them confidently.
Fluid Line Height and Spacing
Font size isn't the only thing that should respond to screen size.
Line Height
Large headings need tighter line height than body text. A unitless line-height scales with the font size, so you only need to set it per role:
body {
line-height: 1.6;
}
h1,
h2 {
line-height: 1.1;
}
h3,
h4 {
line-height: 1.25;
}
For a smoother approach, some designers compute line height from the font size, like line-height: calc(1em + 0.5rem). That gives large text proportionally tighter spacing and small text more generous spacing from a single rule.
Spacing That Matches the Type
Margins and paddings that stay fixed while the type grows make layouts feel cramped on large screens. Apply the same fluid technique to spacing:
:root {
--space-s: clamp(0.75rem, 0.6667rem + 0.4167vw, 1rem);
--space-m: clamp(1rem, 0.8333rem + 0.8333vw, 1.5rem);
--space-l: clamp(1.5rem, 1.1667rem + 1.6667vw, 2.5rem);
}
.prose > * + * {
margin-block-start: var(--space-m);
}
.section {
padding-block: var(--space-l);
}
Line Length
Fluid font sizes pair well with a line length limit measured in ch, so text never stretches too wide to read comfortably:
.prose {
max-width: 65ch;
}
Because ch is relative to the font size, the maximum width grows along with the text.
Scaling with Containers Instead of the Viewport
A card component can appear in a narrow sidebar and in a wide main column on the same page. Viewport-based sizing gives it the same font size in both places, even though the available space is completely different.
Container query units fix this. The cqi unit is 1% of the nearest query container's inline size, and it can drop straight into the fluid formula:
<div class="card-wrapper">
<article class="card">
<h3 class="card__title">Migrating to edge functions</h3>
<p>What we learned moving our API to the edge.</p>
</article>
</div>
.card-wrapper {
container-type: inline-size;
}
.card__title {
font-size: clamp(1.125rem, 0.9rem + 2.5cqi, 1.75rem);
}
In a narrow sidebar, the title stays near 1.125rem. In a wide column, it can grow to 1.75rem. The slope and intercept maths is the same, but the "viewport" widths in the formula are now container widths. Container query units are supported in all current major browsers.
If an element has no query container ancestor, cqi falls back to the small viewport size, so the declaration still does something sensible.
Accessibility: Don't Break Zoom
This is the part many fluid type implementations get wrong.
WCAG success criterion 1.4.4 requires that text can be resized up to 200% without loss of content or functionality. When a user zooms in, rem and px values get bigger, but vw values don't grow in the same way, since zooming effectively makes the viewport narrower in CSS pixels. If your preferred value is mostly or entirely vw, text barely changes when users zoom.
Some rules to keep fluid type accessible:
- Never use
vwalone. Always combine it with arempart, like0.9167rem + 0.4167vw, so that zooming increases the size. - Always keep the
clamp()minimum and maximum inrem. Those bounds scale with zoom and the user's default font size. - Keep the ratio between maximum and minimum modest. A commonly cited guideline is that if the maximum font size is no more than about 2.5 times the minimum, zooming to 200% will still produce at least a doubling at every viewport width. Very dramatic ranges on headings are where zoom problems usually appear.
- Test it. Zoom your page to 200% at a few window sizes and check that text actually grows.
It's also worth never setting the root font size in px. Leaving html at the browser default, or setting it as a percentage, respects the user's chosen default font size.
Refinements for Better Reading
Fluid sizes are the foundation. A couple of newer properties improve how that text actually wraps:
h1,
h2,
h3 {
text-wrap: balance;
}
p {
text-wrap: pretty;
}
text-wrap: balance evens out line lengths in short, multi-line headings, so you don't get a long line followed by a single orphaned word. text-wrap: pretty makes the browser take more care with the last lines of paragraphs to avoid orphans. balance is widely supported; pretty is supported in Chromium-based browsers and Safari, with other browsers treating it as normal wrapping, which makes it a safe enhancement.
Common Pitfalls
- Forgetting the minimum and maximum. A bare
font-size: 4vwbecomes unreadably small on phones and absurdly large on ultrawide monitors. - Mismatched units in the formula. If your viewport breakpoints are in pixels but font sizes are in rem, convert everything to one unit before computing the slope.
- Fluid sizes on everything. Small UI text like button labels, form inputs, and captions often works better at a fixed
remsize. Fluid type shines for headings and body copy. - Overriding in media queries. If you still add breakpoint-based font sizes on top of fluid ones, the two systems fight each other. Pick one approach per element.
Conclusion
Fluid typography replaces a pile of breakpoint-specific font sizes with a single line: a minimum, a maximum, and a straight path between them. The preferred value in clamp() is just a slope in vw plus an intercept in rem, and once you know how to calculate them, you can build a whole type scale where headings gain drama on larger screens while body text stays comfortable.
Extend the same idea to spacing, use cqi when components should respond to their container, and keep every value anchored in rem so users who zoom still get bigger text. The result is type that looks deliberate on every screen, not just the ones you happened to design for.


