A Sass Wrapper for Readable Fluid Type

Fluid typography has become a practical choice now that clamp() is supported in every evergreen browser. The CSS math function lets type scale smoothly between a minimum and maximum bound across viewport sizes, reducing the need for multiple media queries and trimming file size. Tools like utopia.fyi help generate the necessary values, but the resulting CSS can quickly become a wall of dense calculations. For large projects, keeping that code readable and maintainable matters as much as the output itself.

The issue is legibility. A raw clamp() expression buries the design intent:

clamp(1.44rem, 3.44vw + 0.75rem, 2.81rem)

A small Sass function can translate these cryptic values into something a developer can parse at a glance, without losing the power of clamp().

Defining the Target Behavior

Before writing any code, it helps to state the goal. We want a minimum font size that holds steady until the viewport crosses a minimum breakpoint. After that, the font should scale proportionally, reaching a maximum font size exactly when the viewport hits a maximum breakpoint. Both the font sizes and breakpoints are inputs.

The minimum and maximum font sizes map directly to the first and third arguments of clamp():

clamp(1rem, [?], 2rem)

The preferred value in the middle is where the math happens. The core equation is the slope of the line between the two points:

(maximum font-size - minimum font-size) / (maximum breakpoint - minimum breakpoint)

Building the Basic Function

Create a fluid-typography.scss file. Start by importing sass:math and declaring the function signature with the four inputs:

@use "sass:math";

@function fluid($min-size, $max-size, $min-breakpoint, $max-breakpoint, $unit: vw) {
 
}

The slope is the first calculation, handled with sass:math:

@function fluid($min-size, $max-size, $min-breakpoint, $max-breakpoint, $unit: vw) {
 $slope: math.div($max-size - $min-size, $max-breakpoint - $min-breakpoint);
}

To build a viewport-based value, multiply the slope by 100:

$slope-to-unit: $slope * 100;

The intercept completes the linear equation:

$intercept: $min-size - $slope * $min-breakpoint;

With both pieces, the function can return a complete clamp() expression:

@return clamp(#{$min-size}, #{$slope-to-unit}#{$unit} + #{$intercept}, #{$max-size});

Calling the function yields the expected fluid type:

h1 {
   font-size: #{fluid(1rem, 2rem, 25rem, 62.5rem)}
}

Defaulting to Viewport Width

Width is the sensible default for most fluid typography, but the same function works for vertical spacing driven by viewport height. As a quick adjustment, the output unit and breakpoints can be swapped to target height:

h1 {
   font-size: #{fluid(1rem, 2rem, 25rem, 62.5rem, vh)}
}

Refining the Interface

The first version works, but it expects all inputs in rem. Design files typically provide pixel values. Passing pixels as viewport breakpoints also feels more natural to most developers. The solution is to keep pixel inputs while converting the output back to rem for accessibility benefits.

This requires a helper to convert pixels to rem. Note that the conversion assumes the standard browser default of 16px for 1rem, so it won't be accurate if that base value has been overridden.

@function px-to-rem($px) {
    $rems: math.div($px, 16px) * 1rem;
    @return $rems;
}

With the helper in place, update the main function. It takes pixel values for everything, converts the font sizes to rem, and outputs the clamp() accordingly:

@function fluid($min-size, $max-size, $min-breakpoint, $max-breakpoint, $unit: vw) {
    $slope: math.div($max-size - $min-size, $max-breakpoint - $min-breakpoint);
    $slope-to-unit: $slope * 100;
    $intercept-rem: px-to-rem($min-size - $slope * $min-breakpoint);
    $min-size-rem: px-to-rem($min-size);
    $max-size-rem: px-to-rem($max-size);
    @return clamp(#{$min-size-rem}, #{$slope-to-unit}#{$unit} + #{$intercept-rem}, #{$max-size-rem});
}

Input using pixels for both font sizes and breakpoints:

font-size: #{fluid(16px, 32px, 320px, 960px)}

This now produces a clean result:

font-size: clamp(1rem, 2.5vw + 0.5rem, 2rem);

Handling Messy Output

Clean examples hide a practical problem. If the maximum font size isn't a friendly number like 32px, the math results in long decimal values. An input like 31px generates an unwieldy output:

font-size: #{fluid(16px, 31px, 320px, 960px)}
font-size: clamp(1rem, 2.34375vw + 0.53125rem, 1.9375rem);

To improve readability and save bytes, a round function is a useful addition. It takes any number and rounds it to a specified number of decimals:

@function round($number, $decimals: 0) {
    $n: 1;
    @if $decimals > 0 {
        @for $i from 1 through $decimals {
            $n: $n * 10;
        }
    }
    @return math.div(math.round($number * $n), $n);
}

With rounding applied to the output, the results are easier to scan and consistent. A default of two decimals works well for most cases:

@function fluid($min-size, $max-size, $min-breakpoint, $max-breakpoint, $unit: vw) {
    $slope: math.div($max-size - $min-size, $max-breakpoint - $min-breakpoint);
    $slope-to-unit: round($slope * 100, 2);
    $intercept-rem: round(px-to-rem($min-size - $slope * $min-breakpoint), 2);
    $min-size-rem: round(px-to-rem($min-size), 2);
    $max-size-rem: round(px-to-rem($max-size), 2);
    @return clamp(#{$min-size-rem}, #{$slope-to-unit}#{$unit} + #{$intercept-rem}, #{$max-size-rem});
}

The same example now compiles to a cleaner value:

font-size: #{fluid(16px, 31px, 320px, 960px)};
font-size: clamp(1rem, 2.34vw + 0.53rem, 1.94rem);

Setting a Default Breakpoint

Repeating viewport breakpoints in every call is still verbose. Add default arguments to the function so the breakpoint only appears when an exception is required:

$default-min-bp: 320px;
$default-max-bp: 960px;

@function fluid($min-size, $max-size, $min-breakpoint: $default-min-bp, $max-breakpoint: $default-max-bp, $unit: vw) {
    // ...
}

This lets a call omit the viewports entirely:

font-size: #{fluid(16px, 31px)};

The function then resolves to the same fluid type behavior:

font-size: clamp(1rem, 2.34vw + 0.53rem, 1.94rem);

For a complete reference, the full function with rounding and defaults is below:

@use 'sass:math';

$default-min-bp: 320px;
$default-max-bp: 960px;

@function round($number, $decimals: 0) {
    $n: 1;
    @if $decimals > 0 {
        @for $i from 1 through $decimals {
            $n: $n * 10;
        }
    }
    @return math.div(math.round($number * $n), $n);
}

@function px-to-rem($px) {
    $rems: math.div($px, 16px) * 1rem;
    @return $rems;
}

@function fluid($min-size, $max-size, $min-breakpoint: $default-min-bp, $max-breakpoint: $default-max-bp, $unit: vw) {
    $slope: math.div($max-size - $min-size, $max-breakpoint - $min-breakpoint);
    $slope-to-unit: round($slope * 100, 2);
    $intercept-rem: round(px-to-rem($min-size - $slope * $min-breakpoint), 2);
    $min-size-rem: round(px-to-rem($min-size), 2);
    $max-size-rem: round(px-to-rem($max-size), 2);
    @return clamp(#{$min-size-rem}, #{$slope-to-unit}#{$unit} + #{$intercept-rem}, #{$max-size-rem});
}

Accessibility Caution

The convenience of clamp() comes with a specific risk. Using vw units or capping the maximum font size can prevent a user from scaling text to 200% of its original size in some browser configurations. That scenario is a known WCAG failure point. While browser support is strong, anything that limits how large text can be zoomed warrants careful testing. Adrian Roselli has documented specific examples of where responsive type and browser zoom can conflict.

When used intentionally, a tidy Sass function makes fluid typography an approachable addition to any project, and a noticeable upgrade to existing ones.