Available For Front-End & Design Systems Contracts – Get In Touch

Always Twisted

Building Fluid Typographic Scales with clamp() and :heading()

In the last article, I showed how to use explicit :heading(N) level mapping with pow() to create mathematical typographic scales. This article extends that approach to fluid scaling with clamp(), so your heading sizes adapt smoothly to the viewport instead of jumping at breakpoints.

The Fluid Typography Formula

Fluid typography scales smoothly with the viewport instead of jumping at breakpoints. With clamp(), you define a minimum size, a maximum size, and the viewport boundaries—the maths calculates the scaling automatically, keeping text within those bounds.

The underlying formula is a linear ramp between your minimum and maximum viewport widths:

Code languagecss
font-size: clamp(
  min-size,
  min-size + (max-size - min-size) *
    ((100vw - min-viewport) / (max-viewport - min-viewport)),
  max-size
);

Where:

You can calculate these values by hand, or use tools like Utopia, Fluid Typography, or Type Fluidity to experiment with different ratios and sizes before implementing.

Implementing with :heading()

The :heading() pseudo-class lets you write the sizing logic once in a single :heading rule, then map each heading level to a number. Compare that to writing separate font-size calculations for h1, h2, h3, h4, h5, h6.

Why explicit level mapping: :heading(N) { --heading-level: X; } stays reliable regardless of your HTML structure—h1 stays h1 even with repeated or skipped heading levels.

By writing the sizing logic in one :heading block, all the maths lives in a single place. To change how the scale works, you edit one place instead of duplicating calculations across h1, h2, h3, h4, h5, h6.

The level mapping is simple, :heading(1) { --heading-level: 5; } is a one-liner. You set a number rather than writing the entire clamp() calculation for each heading.

Because you're explicitly mapping :heading(N) to --heading-level values, your headings size correctly regardless of HTML structure. h3 stays h3 even if your markup has repeated heading levels.

Finally, it's design system friendly. Change the :root variables once, and every heading updates instantly across your entire project.

Why h1 = level 5, not 1 or 6? The maths works with exponents. An exponent of 0 gives you the base size: h6 = 1rem × 1.2^0 = 1rem. Counting up from 0 means h1's larger exponent (5) naturally produces a larger size: 1rem × 1.2^5 ≈ 2.49rem. If we started h1 at 1, the scale would be inverted. If we started at 6, h6 would be 1rem × 1.2^6, not the base size.

Code languagecss
:root {
  /* Viewport boundaries for fluid scaling */
  --fluid-min: 375px; /* where scaling starts */
  --fluid-max: 1440px; /* where scaling stops */
  --fluid-range: calc(var(--fluid-max) - var(--fluid-min));

  /* Base font size: min at 375px viewport, max at 1440px */
  --base-min: 1rem;
  --base-max: 1.125rem;

  /* Musical scale ratios (pick one by changing --scale-min and --scale-max) */
  --scale-minor-second: 1.067;
  --scale-major-second: 1.125;
  --scale-minor-third: 1.2;
  --scale-major-third: 1.25;
  --scale-perfect-fourth: 1.333;
  --scale-augmented-fourth: 1.414;
  --scale-perfect-fifth: 1.5;
  --scale-golden-ratio: 1.618;

  /* Set your scales here */
  --scale-min: var(--scale-minor-third);
  --scale-max: var(--scale-perfect-fourth);
}

:heading {
  --exponent: var(--heading-level);

  /* Calculate min and max sizes for this heading level */
  --size-min: calc(var(--base-min) * pow(var(--scale-min), var(--exponent)));
  --size-max: calc(var(--base-max) * pow(var(--scale-max), var(--exponent)));

  /* Linear ramp: (max - min) / viewport-range scaled to 100vw */
  --fluid-rate: calc(
    (var(--size-max) - var(--size-min)) * 100 / var(--fluid-range)
  );

  font-size: clamp(
    var(--size-min),
    var(--size-min) + var(--fluid-rate) * (100vw - var(--fluid-min)) / 100,
    var(--size-max)
  );
}

:heading(1) {
  --heading-level: 5;
}
:heading(2) {
  --heading-level: 4;
}
:heading(3) {
  --heading-level: 3;
}
:heading(4) {
  --heading-level: 2;
}
:heading(5) {
  --heading-level: 1;
}
:heading(6) {
  --heading-level: 0;
}

Each heading level gets its own fluid rate because the size-range grows exponentially with the exponent. h1 scales more aggressively than h6, maintaining the typographic ratio across all viewport sizes.

Accessibility Note: This approach complies with WCAG 1.4.4 (Resize Text) because the clamp() min/max values use rem units, which scale with browser zoom. Viewport units only control the rate of change between viewport widths—they don't prevent zooming. Always use rem (never px) and test with browser zoom at different viewport widths.

Browser Support

clamp() has excellent support across all modern browsers (Chrome 79+, Firefox 75+, Safari 13.1+). The :heading() pseudo-class and pow() function are currently experimental and only available in Safari Technology Preview.

If you want to track progress or add support signals for the missing pieces:

🦋 - likes

    🔄 - reposts

      Like this post on Bluesky

      Want to ensure your website meets accessibility standards and works for everyone?

      I'll audit and enhance your HTML, CSS, and JavaScript for WCAG compliance and inclusivity.

      get in touch!