font-size: clamp(1rem, 0.875rem + 0.5vw, 1.5rem)— fluid typography is the standard pattern now. One line, no media queries, viewport-linked. But once you decide "16px at 320px viewport, 24px at 1280px," nobody computes the middle coefficients by hand. We all reach for Utopia.fyi. I built that kind of calculator in 500 lines of vanilla JS to break down the math — both the linear-interpolation formula and the rem/vw split that keeps user font scaling working.
🌐 Demo: https://sen.ltd/portfolio/css-clamp-calc/
📦 GitHub: https://github.com/sen-ltd/css-clamp-calc
What clamp() replaces
/* Old: stepped media queries */
font-size: 16px;
@media (min-width: 768px) { font-size: 20px; }
@media (min-width: 1200px) { font-size: 24px; }
/* New: viewport-linear with auto-clamping ends */
font-size: clamp(1rem, 0.875rem + 0.5vw, 1.5rem);
The bottom version scales linearly with viewport width between two reference points and stays flat outside them. No breakpoints, no JS, no recalc on resize — the browser does it.
The math inside the clamp
Goal: at viewport 320px, font-size should be 16px; at viewport 1280px, 24px. Linear interpolation in between:
size(vwPx) = 16 + (24 - 16) × (vwPx - 320) / (1280 - 320)
= 16 + 8 × (vwPx - 320) / 960
= (16 - 8·320/960) + (8/960) × vwPx
= 13.333 + 0.00833 × vwPx [px]
Now express it in CSS units. A vw is 1% of the viewport, so vwPx = 100·vw:
size = 13.333 + 0.00833 × 100 × vw
= 13.333 + 0.833 × vw [px]
Convert px → rem (16px = 1rem):
intercept = 13.333 / 16 = 0.833 rem
slope = 0.833 vw (vw stays as-is — it's a viewport ratio, not an absolute length)
Wrap with clamp:
font-size: clamp(1rem, 0.833rem + 0.833vw, 1.5rem);
In code:
export function fluidLinear(minPx, maxPx, minVwPx, maxVwPx) {
if (maxVwPx === minVwPx) {
return { slopeVw: 0, interceptRem: minPx / 16, slopePx: 0, interceptPx: minPx };
}
const slopePx = (maxPx - minPx) / (maxVwPx - minVwPx);
const interceptPx = minPx - slopePx * minVwPx;
const slopeVw = slopePx * 100;
const interceptRem = interceptPx / 16;
return { slopeVw, interceptRem, slopePx, interceptPx };
}
The accessibility trick: rem for the intercept
This is the part many homegrown calculators get wrong.
/* ❌ Pixel-hardcoded — overrides the user's font scaling */
font-size: clamp(16px, 13.33px + 0.833vw, 24px);
/* ✅ rem-based — respects the user's root font size */
font-size: clamp(1rem, 0.833rem + 0.833vw, 1.5rem);
If a user goes to Chrome → Settings → Appearance → Font size: Large (20px):
- Pixel version: nothing changes. Your "accessible-by-default" site silently ignored their setting.
- rem version:
1rem = 20px, the whole thing scales up by 25%.
The slope stays in vw because vw is a ratio of the viewport — it doesn't need rem conversion. Only the intercept (which has length semantics) gets rem.
A surprising number of "build your own clamp" snippets on Stack Overflow emit pixels for both halves. They work, but they silently kill accessibility.
What clamp() clips
clamp(min, preferred, max) returns:
-
maxwhen preferred > max -
minwhen preferred < min -
preferredotherwise
At viewports below minVw, the curve stays flat at min. Above maxVw, flat at max. In between, the linear function rules. Media queries replaced by one CSS function.
In the tool, the same logic drives the preview:
export function evalAtViewport(minPx, maxPx, minVwPx, maxVwPx, vwPx) {
const { slopePx, interceptPx } = fluidLinear(minPx, maxPx, minVwPx, maxVwPx);
const linear = interceptPx + slopePx * vwPx;
return Math.min(maxPx, Math.max(minPx, linear));
}
Drag the viewport slider, the preview text rescales in real time using this function. It's identical to what the browser will do at layout.
"Negative intercept" is correct, not a bug
For steep slopes (large size delta), the intercept comes out negative:
minPx=10, maxPx=60, minVwPx=320, maxVwPx=1280
slopePx = 50/960 = 0.052
interceptPx = 10 − 0.052 × 320 = −6.67
The CSS:
font-size: clamp(0.625rem, -0.417rem + 5.208vw, 3.75rem);
"−0.417rem" looks scary but it's right. At viewport = 320px:
-0.417rem + 5.208vw = -6.67px + 5.208 × 3.2px = -6.67 + 16.67 = 10px ✓
Equals minPx = 10. The clamp lower bound was never going to trigger at exactly minVw because the linear function lands right on min there by construction. The negative intercept is what makes the geometry close. Don't "fix" it; you'll get a different curve.
SVG chart: see clamp() clipping in motion
Just showing the formula isn't enough. The tool draws a chart of font-size vs viewport with two lines:
-
Clamped output (solid blue) — flat outside
[minVw, maxVw], linear inside - Raw linear (dashed dim blue) — extends past both ends, showing what clamp() actually clipped
Moving the viewport slider drags a pink cursor across the chart, and the preview text rescales in sync. The "ah, clamp() is that" moment lands.
Boundary tests
test("at minVw: returns minPx exactly", () => {
const v = evalAtViewport(16, 24, 320, 1280, 320);
assert.ok(Math.abs(v - 16) < 1e-9);
});
test("below minVw: returns minPx", () => {
const v = evalAtViewport(16, 24, 320, 1280, 100);
assert.equal(v, 16);
});
test("at maxVw: returns maxPx exactly", () => {
const v = evalAtViewport(16, 24, 320, 1280, 1280);
assert.ok(Math.abs(v - 24) < 1e-9);
});
test("midpoint is the average", () => {
const v = evalAtViewport(16, 24, 320, 1280, 800);
assert.ok(Math.abs(v - 20) < 1e-9);
});
Splitting "endpoint exactness" from "outside-range clamping" into separate tests is the load-bearing part. Without that, an off-by-one in either the formula or the clamp slips through.
7 presets
export const PRESETS = [
{ label: "Body text", minPx: 16, maxPx: 18, minVwPx: 320, maxVwPx: 1280 },
{ label: "Lead paragraph", minPx: 18, maxPx: 22, minVwPx: 320, maxVwPx: 1280 },
{ label: "H3 heading", minPx: 20, maxPx: 28, minVwPx: 320, maxVwPx: 1440 },
{ label: "H2 heading", minPx: 24, maxPx: 40, minVwPx: 320, maxVwPx: 1440 },
{ label: "H1 hero", minPx: 32, maxPx: 72, minVwPx: 320, maxVwPx: 1440 },
{ label: "Display", minPx: 48, maxPx: 120, minVwPx: 320, maxVwPx: 1600 },
{ label: "Tight", minPx: 14, maxPx: 16, minVwPx: 480, maxVwPx: 960 },
];
The "Tight" preset has a narrow viewport range — scales only in the middle (480–960px) and stays flat at the extremes. Useful when you want fluidity in the iPad-ish range without pushing larger than 16px on actual desktops.
Architecture
clamp.js ← fluidLinear, toClamp, evalAtViewport, validateConfig (DOM-free, 19 tests)
presets.js ← 7 typography presets
app.js ← UI glue (input → math → SVG chart → preview)
clamp.js is pure. Node tests cover the linear-interpolation endpoints, clamping behaviour above/below/at the range edges, CSS string emission, negative-intercept rendering, and config validation. The UI layer is SVG strings and event listeners — no chart library, no framework.
Try it
Pick "H1 hero," drag the viewport slider. Watch the preview text scale smoothly through the middle and pin at the ends. Then resize your actual browser window — same behaviour, no media queries fired.
Takeaways
-
Fluid typography is one CSS function,
clamp(min, intercept + slope·vw, max). - Slope and intercept come from the endpoint constraints — straight algebra, no magic.
- Use rem for the intercept, vw for the slope. rem preserves user font scaling (accessibility); vw is a viewport ratio that doesn't need conversion.
- Negative intercepts are correct. Large size deltas make them negative, and the geometry closes at the lower endpoint regardless.
- Pair the formula with a chart. Showing the clipped curve next to the un-clipped linear makes "what clamp() does" instantly readable.
- Test endpoint exactness and out-of-range clamping separately. Otherwise off-by-one errors hide.
This is OSS portfolio #254 from SEN LLC (Tokyo). Built in vanilla JS, no build step. https://sen.ltd/portfolio/

Top comments (0)