Fluid Type with clamp()

Responsive typography has traditionally relied on media queries or JavaScript hacks. The CSS clamp() function offers a cleaner way: scale text linearly between minimum and maximum sizes as the viewport width changes, all in a single declaration.

clamp() takes three values:

clamp(minimum, preferred, maximum);

The function returns the preferred value, unless that value falls below the minimum (then it returns the minimum) or above the maximum (then it returns the maximum). For fluid typography, the preferred value is typically a viewport-based formula:

.banner {
  width: clamp(200px, 50% + 20px, 800px); /* Yes, you can do math inside clamp()! */
}

Building a Fluid Font-Size Rule

Suppose you want an element's font-size to be 1rem at viewport widths of 360px or below, and 3.5rem at widths of 840px or above. In clamp() terms:

1rem   = 360px and below
Scaled = 361px - 839px
3.5rem = 840px and above

For viewport widths between 361px and 839px, the font size must scale linearly. At 600px — halfway between 360px and 840px — the font size should be exactly halfway between 1rem and 3.5rem, i.e., 2.25rem.

Line chart with the vertical axis measured in font size rem unites from 0 to 4, and the horizontal axis measuring viewport width from 0 to 1,060 pixels. There are four blue points on the grid with a blue line connecting them.

The Four Steps to Linearity

This is a case of linear interpolation: deriving intermediate values between two known data points. Here's the process:

  1. Pick your endpoints. Choose minimum and maximum font sizes (1rem and 3.5rem) and corresponding viewport widths (360px and 840px).
  2. Convert widths to rem. Assuming the default root font size of 16px, the viewport bounds become 22.5rem and 52.5rem.
  3. Calculate the line. The pairs of (viewport width, font size) form two points on an X/Y coordinate system:
A two-dimensional coordinate chart with two points and a red line intersecting them.
(22.5, 1) and (52.5, 3.5)

The slope and Y-axis intersection determine the scaling formula:

slope = (maxFontSize - minFontSize) / (maxWidth - minWidth)
yAxisIntersection = -minWidth * slope + minFontSize

This yields a slope of 0.0833 and a Y-axis intersection of -0.875.

  1. Assemble the clamp() function. The preferred value formula is:
preferredValue = yAxisIntersection[rem] + (slope * 100)[vw]

Resulting in:

.header {
  font-size: clamp(1rem, -0.875rem + 8.333vw, 3.5rem);
}

The font size stops growing past 840px and stops shrinking below 360px. Everything in between scales linearly.

Accounting for Root Font-Size Changes

This approach assumes the root font size is 16px — the browser default. If a user has set their root font size to 18px, converting 360px and 840px to rem by dividing by 16 is wrong.

The only reliable fix is to calculate the values in JavaScript on page load, monitor for font-size changes, and recalculate. A utility function helps:

// Takes the viewport widths in pixels and the font sizes in rem
function clampBuilder( minWidthPx, maxWidthPx, minFontSize, maxFontSize ) {
  const root = document.querySelector( "html" );
  const pixelsPerRem = Number( getComputedStyle( root ).fontSize.slice( 0,-2 ) );

  const minWidth = minWidthPx / pixelsPerRem;
  const maxWidth = maxWidthPx / pixelsPerRem;

  const slope = ( maxFontSize - minFontSize ) / ( maxWidth - minWidth );
  const yAxisIntersection = -minWidth * slope + minFontSize

  return `clamp( ${ minFontSize }rem, ${ yAxisIntersection }rem + ${ slope * 100 }vw, ${ maxFontSize }rem )`;
}

// clampBuilder( 360, 840, 1, 3.5 ) -> "clamp( 1rem, -0.875rem + 8.333vw, 3.5rem )"

There's no native event for root font-size changes, so you'd need a setInterval check — which comes at a performance cost. That's an extreme edge case, but it's the only way to make the approach fully responsive to user preferences.

Preventing Text Reflow

Fine control over font sizing enables a less common trick: keeping text at a constant line count across viewport widths. With a simple fluid type rule, text reflows as the width changes:

It has a number of lines at a certain viewport width…
…and wraps it’s lines to fit another width

With careful sizing, text can retain the same line breaks at every viewport:

Viewport width = 400px
Viewport width = 740px

Matching Font Size to Viewport Width

The ratio between font size and viewport width must stay constant. In this example, the font goes from 1rem at 320px to 3rem at 960px:

320 / 1 = 320
960 / 3 = 320

Using the earlier clampBuilder() function:

const text = document.querySelector( "p" );
text.style.fontSize = clampBuilder( 320, 960, 1, 3 );

This alone isn't enough. You also need to set the text container's width using the ch unit — the width of the glyph "0" in the element's font. To fill the viewport horizontally, the container must be Xch wide, where X is the viewport width divided by the font's ch size at the minimum width.

This snippet measures an element's ch size:

// Returns the width, in pixels, of the "0" glyph of an element at a desired font size
function calculateCh( element, fontSize ) {
  const zero = document.createElement( "span" );
  zero.innerText = "0";
  zero.style.position = "absolute";
  zero.style.fontSize = fontSize;

  element.appendChild( zero );
  const chPixels = zero.getBoundingClientRect().width;
  element.removeChild( zero );

  return chPixels;
}

Applying the width:

function calculateCh( element, fontSize ) { ... }

const text = document.querySelector( "p" );
text.style.fontSize = clampBuilder( 320, 960, 1, 3 );
text.style.width = `${ 320 / calculateCh(text, "1rem" ) }ch`;
Umm, who invited you to the party, scrollbar?

That introduces horizontal overflow. The problem: vw units include the width of the vertical scrollbar. The text width is calculated against the full viewport width, but the visible area is viewport width − scrollbar width, causing overflow.

You can't avoid this by using a scrollbar-exclusive metric, because the vw unit inside clamp() also accounts for the scrollbar. The font scales along the full viewport width. To prevent overflow without breaking the ratio, scale the final width down slightly:

text.style.width = `${ 320 / calculateCh(text, "1rem") }ch`;

Multiplying by 0.9 gives the text a width of 90% of the viewport, comfortably clearing the scrollbar:

function calculateCh( element, fontSize ) { ... }

const text = document.querySelector( "p" );
text.style.fontSize = clampBuilder( 20, 960, 1, 3 );
text.style.width = `${ 320 / calculateCh(text, "1rem" ) * 0.9 }ch`;
So long, scrollbar!

Subtracting a few pixels from the viewport width, rather than scaling, is tempting but wrong — it breaks the width-to-font ratio and reintroduces reflow:

text.style.width = `${ ( 320 - 30 ) / calculateCh( text, "1rem" ) }ch`;
Viewport width = 650px
Viewport width = 670px

Two caveats apply. First, the text width must always be a percentage of the viewport width. Second, font loading must be consistent across devices: font-family: sans-serif resolves to Arial on Chrome for Windows but Roboto on Chrome for Android, and differing glyph geometry causes reflow. Monospaced fonts yield the most predictable results.

Non-Reflowing Text Inside a Container

The technique translates to containers: apply font-size and the width rule to the parent, and let children inherit. Block-level elements like paragraphs and headings fill the container's width automatically.

There are advantages to container-level application: children resize proportionally without individual rules, and changing one element's font size in em units keeps it relative to the container.

Non-reflowing text remains finicky, but it's a subtle effect that adds polish. The same clamp() technique applies to any CSS property accepting a length unit — though often a plain font-size: 1rem is all you need. This approach is about having control available when the design calls for it.