CSS Clamp

CSS-in-JS

CSS clamp() in CSS-in-JS and React

One small TypeScript helper for fluid font sizes and spacing, and how to use it with styled-components, Emotion, vanilla-extract, CSS Modules and inline styles.

clamp() is plain CSS, so it works in every CSS-in-JS library without a plugin. What you do want is a way to create the values without pasting them from a generator one by one. A short helper does that.

The helper uses the same formula as the clamp() generator, so both give the same output to the third decimal. The deep dive explains the maths.

The fluid() helper

// fluid.ts
const round = (value: number) => Number.parseFloat(value.toFixed(3));

/** A clamp() that grows from minPx at minVw to maxPx at maxVw. Inputs in px, output in rem. */
export const fluid = (minPx: number, maxPx: number, minVw = 360, maxVw = 1440) => {
    const slope = (maxPx - minPx) / (maxVw - minVw);
    const intercept = (minPx - slope * minVw) / 16;
    const min = Math.min(minPx, maxPx) / 16;
    const max = Math.max(minPx, maxPx) / 16;

    return `clamp(${round(min)}rem, ${round(intercept)}rem + ${round(slope * 100)}vw, ${round(max)}rem)`;
};
  • slope is how many px the value grows for each px of viewport. Times 100, it becomes the vw part.
  • intercept is where that line meets a 0px viewport. Divided by 16, it becomes the rem part.
  • Math.min and Math.max keep the bounds in order, so a value that shrinks on wider screens also works.
  • parseFloat(toFixed(3)) rounds to 3 decimals and drops trailing zeros, so you get 4rem and not 4.000rem.

Run with Node, it returns:

fluid(36, 64)            // clamp(2.25rem, 1.667rem + 2.593vw, 4rem)
fluid(30, 48)            // clamp(1.875rem, 1.5rem + 1.667vw, 3rem)
fluid(16, 18)            // clamp(1rem, 0.958rem + 0.185vw, 1.125rem)
fluid(16, 64)            // clamp(1rem, 0rem + 4.444vw, 4rem)
fluid(12, 24, 320, 1280) // clamp(0.75rem, 0.5rem + 1.25vw, 1.5rem)

Generate your tokens once

Do not call fluid() in every component with different numbers. Build a small theme object once, with one viewport range for everything, and import it where you need it. The values stay consistent and the maths runs one time.

// theme.ts
import { fluid } from './fluid';

export const theme = {
    text: {
        h1: fluid(36, 64),
        h2: fluid(30, 48),
        h3: fluid(24, 36),
        body: fluid(16, 18),
    },
    space: {
        section: fluid(16, 64),
        gutter: fluid(16, 32),
    },
} as const;

styled-components

Interpolate the helper or a theme value straight into the template literal. The result is a normal string, so styled-components treats it like any other value.

import styled from 'styled-components';

import { fluid } from './fluid';
import { theme } from './theme';

export const Title = styled.h1`
    font-size: ${fluid(36, 64)};
    line-height: 1.1;
`;

export const Section = styled.section`
    padding-block: ${theme.space.section};
`;

If you already pass a theme through ThemeProvider, add the fluid tokens to it and read them with ${({ theme }) => theme.text.h1}.

Emotion

Emotion accepts the same string in object styles and in the css prop:

/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';
import styled from '@emotion/styled';

import { fluid } from './fluid';

export const Title = styled.h1({
    fontSize: fluid(36, 64),
    lineHeight: 1.1,
});

export const Card = ({ children }: { children: React.ReactNode }) => (
    <div css={css({ padding: fluid(16, 40) })}>{children}</div>
);

vanilla-extract

vanilla-extract runs your .css.ts files at build time and writes a static stylesheet. fluid() runs during the build, and the browser only receives the finished clamp() values. Put them in a global theme so they become custom properties:

// styles.css.ts
import { createGlobalTheme, style } from '@vanilla-extract/css';

import { fluid } from './fluid';

export const vars = createGlobalTheme(':root', {
    text: {
        h1: fluid(36, 64),
        body: fluid(16, 18),
    },
    space: {
        section: fluid(16, 64),
    },
});

export const title = style({
    fontSize: vars.text.h1,
    lineHeight: 1.1,
});

export const section = style({
    paddingBlock: vars.space.section,
});

The generated CSS contains no trace of the helper:

:root {
    --text-h1__r5u5r70: clamp(2.25rem, 1.667rem + 2.593vw, 4rem);
    --text-body__r5u5r71: clamp(1rem, 0.958rem + 0.185vw, 1.125rem);
    --space-section__r5u5r72: clamp(1rem, 0rem + 4.444vw, 4rem);
}
.styles_title__r5u5r73 {
    font-size: var(--text-h1__r5u5r70);
    line-height: 1.1;
}

CSS Modules with custom properties

CSS Modules are plain CSS, so they cannot call a JavaScript function. Set the tokens as custom properties once, for example on the root layout, and use var() in your modules.

// layout.tsx
import type { CSSProperties, ReactNode } from 'react';

import { theme } from './theme';

const tokens = {
    '--text-h1': theme.text.h1,
    '--space-section': theme.space.section,
} as CSSProperties;

export const Layout = ({ children }: { children: ReactNode }) => (
    <body style={tokens}>{children}</body>
);
/* hero.module.css */
.hero {
    padding-block: var(--space-section);
}

.title {
    font-size: var(--text-h1);
}

If you do not need the values in JavaScript at all, skip the helper and paste the output of the generator into a global stylesheet instead.

Inline React styles

Inline styles take the same string. Pass it to a camelCase property and React writes it to the element.

import { fluid } from './fluid';
import { theme } from './theme';

export const Hero = () => (
    <section style={{ paddingBlock: theme.space.section }}>
        <h1 style={{ fontSize: fluid(36, 64) }}>Fluid heading</h1>
    </section>
);

The browser still does all the resizing. No resize listener or state is needed, and nothing rerenders when the window changes size.

Tips

  • Keep one viewport range for every token, so font sizes and spacing grow in step. The defaults in the helper are a good place to set it.
  • Prefer custom properties for shared tokens. They work in every approach on this page, so you can switch libraries without touching the values.
  • Use Tailwind or Sass as well? See the Tailwind CSS and Sass guides for the same values there.
  • New to clamp()? The guide explains how to read a value.

CSS-in-JS clamp() FAQ

Can I use clamp() in CSS-in-JS?

Yes. clamp() is plain CSS, so any library that accepts a CSS string accepts it. In styled-components and Emotion you write it like any other value. In object styles and React inline styles you pass it as a string, for example fontSize: "clamp(1rem, 0.958rem + 0.185vw, 1.125rem)".

How do I use clamp() with styled-components?

Write the value in the template literal, or interpolate a helper that builds it: font-size: ${fluid(36, 64)}. For a design system, put the values in your theme object once and read them with props.theme in each component.

Does calculating clamp() in JavaScript slow down my React app?

Barely. The helper is a few multiplications. Still, call it once when you define your theme instead of in every render. With vanilla-extract the helper runs at build time, so it never reaches the browser at all.

Why use rem in a fluid font size instead of only vw?

A size in only vw ignores the browser font size and does not grow when users zoom in. Keeping a rem part in the preferred value and rem bounds means text still responds to user settings, which matters for accessibility.

Should fluid values live in JavaScript or in CSS custom properties?

Generate them once in JavaScript and expose them as custom properties. Your components then use var(--text-h1), which works in any styling approach, and the browser does the resizing with no JavaScript running on resize.