DEV Community

SEN LLC
SEN LLC

Posted on

Building a clamp() Calculator for Fluid Typography — Linear Interpolation + the Accessibility Trick Most Tools Miss

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

Screenshot

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);
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Wrap with clamp:

font-size: clamp(1rem, 0.833rem + 0.833vw, 1.5rem);
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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:

  • max when preferred > max
  • min when preferred < min
  • preferred otherwise

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));
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The CSS:

font-size: clamp(0.625rem, -0.417rem + 5.208vw, 3.75rem);
Enter fullscreen mode Exit fullscreen mode

"−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 ✓
Enter fullscreen mode Exit fullscreen mode

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:

  1. Clamped output (solid blue) — flat outside [minVw, maxVw], linear inside
  2. 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);
});
Enter fullscreen mode Exit fullscreen mode

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 },
];
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)