Your h1 is set to 3rem. On a 27 inch monitor that reads as a headline. On a 360 pixel phone it wraps to four lines and shoves the first paragraph off the screen. The usual fix is a media query at 768 pixels that drops it to 2rem, and now the heading has two sizes and nothing in between: at 767 pixels it is small, at 769 it is large, and every width in the gap gets the wrong one of the two.
Then there are the other seven levels. h2 to h6 were chosen to sit under the old h1, the body copy under those, the small print under the images below that. Eight levels across three breakpoints is twenty four declarations that all have to stay in proportion with each other, by hand, forever.
One clamp() per level replaces the lot. The part that goes wrong is the middle argument. Most examples put a round vw number there and let the two ends land wherever they land, which is why so many fluid scales hit their ceiling at the wrong width, or stop responding when a reader zooms. The middle term is a straight line, and a line through two known points has exactly one answer.
What clamp() actually returns
clamp() takes three lengths and returns the middle one, held between the other two. It is defined as max(MIN, min(VAL, MAX)), worth reading twice: the lower bound is applied last, so if the bounds go in the wrong way round and MIN comes out larger than MAX, MIN wins and MAX is ignored. No error, no warning, just a size that never moves.
The two bounds are the easy part: the size you want on the narrowest screen you care about, and the size you want on the widest. The middle argument is the only one that has to know anything about the viewport, and the only one anybody gets wrong.
The calculator below solves all eight levels at once and prints the finished declarations, with the size at each end in px, rem and pt to check against a design. It runs entirely in your browser: nothing is uploaded and no request goes anywhere.
Fluid type scale calculator
Build a fluid type scale and get the finished clamp() for every level, with the middle term solved as a real slope and intercept rather than guessed at. Everything is worked out in this browser tab, nothing is uploaded and nothing is fetched.
| Level | At 320 px | At 1280 px | font-size | Copy |
|---|---|---|---|---|
| h1 | 47.78 px2.986 rem · 35.83 pt | 61.04 px3.815 rem · 45.78 pt | clamp(2.986rem, 2.71rem + 1.381vw, 3.815rem) |
|
| h2 | 39.81 px2.488 rem · 29.86 pt | 48.83 px3.052 rem · 36.62 pt | clamp(2.488rem, 2.301rem + 0.939vw, 3.052rem) |
|
| h3 | 33.18 px2.074 rem · 24.88 pt | 39.06 px2.441 rem · 29.3 pt | clamp(2.074rem, 1.951rem + 0.613vw, 2.441rem) |
|
| h4 | 27.65 px1.728 rem · 20.74 pt | 31.25 px1.953 rem · 23.44 pt | clamp(1.728rem, 1.653rem + 0.375vw, 1.953rem) |
|
| h5 | 23.04 px1.44 rem · 17.28 pt | 25 px1.562 rem · 18.75 pt | clamp(1.44rem, 1.399rem + 0.204vw, 1.562rem) |
|
| h6 | 19.2 px1.2 rem · 14.4 pt | 20 px1.25 rem · 15 pt | clamp(1.2rem, 1.183rem + 0.083vw, 1.25rem) |
|
| body | 16 px1 rem · 12 pt | 16 px1 rem · 12 pt | 1rem |
|
| small | 13.33 px0.833 rem · 10 pt | 12.8 px0.8 rem · 9.6 pt | clamp(0.8rem, 0.844rem - 0.056vw, 0.833rem) |
small falls to 12.8 px. Below 16 px, iOS Safari zooms the page the moment a visitor focuses a form control set at that size, and it is already at the edge of comfortable reading.
The middle term is the straight line through the two end points: the slope is the size difference divided by the viewport difference, written in vw, and the intercept is what is left of it at zero width, written in rem. rem is read against a 16 px root, the browser default. A larger ratio at the large end means more contrast there, so headings grow faster and small text shrinks further. Body has no step, so it stays at the base size and needs no clamp at all.
Type that fits the window
One declaration per level
Slope and intercept, not a guess
From the narrowest phone outwards
Then it holds steady
A quiet sub-heading
Body copy stays at the base size from the narrowest phone to the widest desktop, because 16 px is already the smallest comfortable reading size and a wider window is no reason on its own to make it larger.
Caption, footnote, image credit.
The preview uses the rounded numbers from the table, so it shows what the browser would render from the CSS you copy, not from a second, more precise calculation. The panel is also held to the chosen width where there is room for it, so the line lengths are honest too.
The middle term is a line, not a percentage
1vw is one hundredth of the viewport width, so font-size: 4vw is a straight line through the origin: 12.8px at a 320 pixel viewport, 51.2px at 1280. You choose the slope and nothing else. Ask for 47.78px at 320 and 61.04px at 1280 and no vw value can do it, because a line through the origin cannot pass through both points.
Add a constant and the line lifts off the origin. Two unknowns now, a slope and an intercept, against two known points, which is exactly enough. The slope is the size difference divided by the viewport difference, times 100 to express it in vw rather than whole viewports. The intercept is what is left of the line at zero width, divided by the root font size to put it in rem.
Target: h1, base 16px, ratio 1.2 at the small end, 1.25 at the large end
S1 = 16 x 1.2^6 = 47.776 px at a viewport of 320 px
S2 = 16 x 1.25^6 = 61.035 px at a viewport of 1280 px
slope = (61.035 - 47.776) / (1280 - 320)
= 0.0138119 px per px -> x100 -> 1.381 vw
intercept = 47.776 - (0.0138119 x 320)
= 43.356 px -> /16 -> 2.710 rem
font-size: clamp(2.986rem, 2.71rem + 1.381vw, 3.815rem);
Every number there is checkable in ten seconds. At a 320 pixel viewport 1.381vw is 4.42px and 2.71rem is 43.36px, adding to 47.78. At 1280 the vw part is 17.68px and the total is 61.04.
You do not need calc() inside clamp(). clamp() is itself a maths function and its arguments are already treated as calculations, so 2.71rem + 1.381vw is valid exactly as written. Twenty Twenty-Four ships a real one in its theme.json, on h4: clamp(1.1rem, 1.1rem + ((1vw - 0.2rem) * 0.767), 1.5rem).
That is the same line in point-slope form. 0.2rem is 3.2px, which is exactly 1vw at a 320 pixel viewport, so the bracket is zero there and the size is precisely the 1.1rem minimum. Solve it the other way and something shows up: the bracket has to reach 0.4rem for the size to hit its 1.5rem ceiling, which needs 1vw to reach 0.7215rem, at a viewport of about 1154 pixels. The theme’s wide layout width is 1280. That heading finishes growing 126 pixels before the layout does.
Why vw on its own fails a zoom test
There is a second reason the constant matters, and it is not about taste. Browser zoom does not scale vw. Zoom to 200 percent and the layout viewport halves in CSS pixels, so a 1280 pixel window reports 640, and every CSS pixel is painted at twice the size. A rem length keeps its value and is painted twice as large, which is the doubling the reader asked for. A vw length halves in CSS pixels, is painted at double, and comes out exactly the physical size it started at. Text set purely in vw does not get bigger when you zoom.
The reader’s own default font size does not move it either. rem is relative to the root font size, so somebody who sets their browser default to 20px gets 25 percent larger text everywhere rem is used and nothing at all where vw is. Page zoom and the default size setting are how most people with low vision actually read the web, and WCAG 1.4.4 asks that text reach 200 percent without the content falling apart.
The rem constant is what rescues that, and the arithmetic is tidy: zooming to 200 percent adds the rem part a second time and leaves the vw part where it was. How much zoom helps therefore depends on how much of the size the constant carries. In the example above it carries 43.36 of the 47.78 pixels at the narrow end, about 91 percent.
Push the end sizes far apart over a short viewport range and the intercept shrinks to zero and then goes negative, which is worse than none: zoom adds that negative constant a second time, so the text gets smaller as the reader tries to enlarge it. A rem term at or below zero means the two sizes are too far apart for the viewport range you gave them.
Spacing is a different question. Twenty Twenty-Five sets several spacing sizes with a bare vw middle term, clamp(30px, 5vw, 50px) among them, which is defensible: nobody needs to enlarge a gap to read it. Type is not a gap.
One ratio, applied eight times
A modular scale is one multiplication repeated. Pick a base size and a ratio, and every level is the base times the ratio raised to its step number. Body is step zero, the small level step minus one, h6 up to h1 steps one to six. With a base of 16 and a ratio of 1.2, h3 is 33.18px and h1 is 47.78px, and neither number was chosen by anybody.
This looks better than eight sizes picked by eye because the eye reads the relationship between consecutive sizes rather than the absolute values, and a constant multiplier keeps that relationship identical at every step. Sizes chosen one at a time drift: two levels land close enough to look like a mistake, or one jumps and the page reads as two designs stitched together. It is the same class of problem as the typography mistakes that give an amateur design away, nothing individually wrong and everything slightly out of relation.
The ratio is where taste enters, and it is the one number the calculator expects you to have an opinion about. It also does not want to be the same at both ends. A ratio compounds, so its effect at step six is dramatic and at step one barely visible. From a 16px base the golden ratio puts h1 at 287px, which on a 360 pixel phone is roughly two characters a line. That is not a headline, it is a logo.
Hence two ratio fields. A smaller ratio at the narrow end keeps the phone readable, a larger one at the wide end lets headings pull away from the body copy once there is room. The defaults, 1.2 and 1.25, are deliberately quiet. A perfect fourth at 1.333 is where most editorial-looking sites sit, and it puts h1 at 89.76px, which needs a wide container to survive.
The three numbers a WordPress theme gives you
Three inputs decide the whole scale: the base size, the viewport range, the ratio. The base is usually 16px, the browser default and the size a reader implicitly agreed to by not changing it. The ratio is the taste decision. The viewport range is the one people get wrong, reaching for the screen width when they want the container width.
Type stops needing to grow when the measure stops growing. In a block theme the measure lives in theme.json under settings.layout: contentSize for ordinary blocks, wideSize for wide ones. Twenty Twenty-Four uses 620px and 1280px, Twenty Twenty-Five 645px and 1340px, Twenty Twenty-Three 650px and 1200px. Set your maximum viewport to the theme’s own wideSize and type finishes growing where the layout does. A heading that keeps swelling out to 1920 looks wrong for a reason: the text under it stopped getting wider hundreds of pixels ago.
Core takes exactly that position. Switch on settings.typography.fluid and WordPress builds the clamp() itself in wp_get_typography_font_size_value(), with a minimum viewport width defaulting to 320px and a maximum read from layout.wideSize when the theme sets one, falling back to 1600px when it does not.
{
"settings": {
"layout": { "contentSize": "620px", "wideSize": "1280px" },
"typography": {
"fluid": true,
"fontSizes": [
{ "slug": "small", "size": "0.9rem", "fluid": false },
{ "slug": "medium", "size": "1.05rem", "fluid": false },
{ "slug": "x-large", "size": "2.5rem",
"fluid": { "min": "1.85rem", "max": "2.5rem" } }
]
}
}
}
Those are Twenty Twenty-Four’s real values, and the pattern is worth copying: fixed sizes at the small end, an explicit min and max for levels that should move, "fluid": false on anything that should not. Give core only a size and it derives the minimum itself with a logarithmic factor, min(max(1 - 0.075 * log2(px), 0.25), 0.75), refusing to go below a floor of 14px. Fourteen is under the 16 this calculator flags, which is a real difference of opinion.
A classic theme has no theme.json, so there the useful button is the one that emits the whole scale as custom properties. Declare them once, reference them everywhere, and put the CSS in a child theme stylesheet so the next parent update does not take it with it.
:root {
/* base 16px, 320 to 1280, ratios 1.2 and 1.25 */
--fs-h1: clamp(2.986rem, 2.71rem + 1.381vw, 3.815rem);
--fs-h2: clamp(2.488rem, 2.301rem + 0.939vw, 3.052rem);
--fs-h3: clamp(2.074rem, 1.951rem + 0.613vw, 2.441rem);
--fs-body: 1rem;
--fs-small: clamp(0.8rem, 0.844rem - 0.056vw, 0.833rem);
}
h1 { font-size: var(--fs-h1); }
h2 { font-size: var(--fs-h2); }
The floor, and the direction of travel
Mobile Safari zooms the whole page when a form control whose computed font-size is below 16px takes focus. It is not styleable and not defeatable from CSS: the reader has to pinch back out. Setting inputs to 16px is the fix, and since inputs commonly inherit the surrounding size, a scale whose small level lands at 14px will trip it wherever that class ends up near a form.
The calculator marks any level whose smallest rendered size falls under 16px in red and names it beneath the table. The word smallest is doing real work there. With 1.2 at the narrow end and 1.25 at the wide end, the small level is 13.33px at 320 and 12.8px at 1280: it shrinks as the screen grows, because a larger ratio at the wide end pushes negative steps down as surely as positive steps up. Its smallest size is at the maximum viewport, so that is the cell that turns red.
The same effect on a heading is a bug, not a quirk. If h1 comes out smaller at your maximum viewport than at your minimum, either the ends went in the wrong way round or the wide-end ratio is below the narrow-end one. The declaration stays valid CSS, because the bounds are always written low first, but the design is inverted: the heading is largest where there is least room for it. Read the two size columns down. Above body, the first number should always be the smaller one.
Type size belongs to the same family as contrast, which is a number rather than an opinion. Both have a threshold that is met or not met, and both get argued about far more than they get measured.
Where the numbers stop
Every rem printed is measured against a 16px root, the browser default, and the note under the table says so. If your theme sets html { font-size: 62.5% } to make mental arithmetic easier, a printed 2.986rem renders at 29.86px instead of the 47.78px it was solved for, and the whole scale comes out five eighths of the size you designed.
The base is the size body gets at every viewport, so body never scales. The ratios only move the levels above and below it. That is deliberate: 16px body copy is 16px on a phone and on a monitor, and readers who need it larger have already told their browser so. If you want the body itself to grow on a wide screen, this is not the tool for it.
Ratios are restricted to 1 through 4. A ratio of 1 at both ends is allowed and gives a flat scale: every level a plain rem, no clamp() anywhere. Body already behaves that way: an identical minimum and maximum is written as a plain rem, not a clamp() holding three copies of one number. Invalid input, a base outside 8 to 200, a ratio outside 1 to 4, a viewport outside 120 to 3840, or a minimum viewport not below the maximum, turns the field red and explains the rule, leaving the last good scale standing rather than blanking the table mid-edit.
Output is rounded to three decimals for rem and vw, two for px and pt, and the preview evaluates those rounded declarations rather than a second, more precise calculation. That is on purpose: the preview shows what the CSS you copied will really do, which occasionally means a level reaches its ceiling a few hundredths of a pixel past the maximum viewport. The slider runs from 320 to 1920 whatever the viewport fields say, so you can see past your own ceiling. Each row is a font-size and nothing more: no line height, no measure, no letter spacing. And the tool cannot appear twice on one page, because its element ids are fixed rather than prefixed per instance.
The value of a scale like this is not the typing it saves, though twenty four declarations collapsing into eight is not nothing. It is that the decisions move to the front. You choose three numbers once, with reasons: 16px because that is what the browser and the reader agreed on, the container width because that is where the text stops getting wider, a ratio because you compared a few and preferred one. Everything else is derived, and derived numbers do not drift.
It is the same shape of decision as building a colour palette from one choice. Pick the seed carefully, let arithmetic produce the rest, and spend your attention on the seed rather than on forty downstream values that will never quite agree. Type and colour are the two systems where that pays immediately, and the two where hand-picked values decay fastest.
Media queries are not the enemy and they do not go away. A sidebar that becomes a stack, a grid that drops to one column, a navigation that turns into a button: those are real changes of state, and a breakpoint is the right tool. Font size is not a change of state. It is a continuous quantity that was implemented with a step function for twenty years because nothing else existed. Something else exists now, and it fits on one line. The exception is text set over a photograph, where the background differs across the frame and readability stops being a size problem and becomes a contrast one.