Skip to main content

jul 02, 2025

🔗 Generate fluid styles with a Tailwind plugin

Tailwind is great and can do a lot of things, but it hasn't yet been adapted to generate fluid styling.

Fluid CSS refers to the ability of setting fluid values to CSS properties that change proportionally with the width of the screen, or the container.

When typography and spacing system adapt fluidly to any size, the design can take full advantage of the available space, and always feel right to any screen size.

# Existing solutions

It's already possible to have fluid CSS, here are two of the most used solutions:

  • Utopia, it's a project, open-source library and web-app that, given in input a set of requirements for the largest and smallest screen sizes, the largest and smallest type size (or spacing system size) and the number of steps, it outputs a set of CSS variables.
:root {
  --step-0: clamp(1rem, 0.7955rem + 0.9091vw, 1.5rem);
  --step-1: clamp(1.2rem, 0.9239rem + 1.2273vw, 1.875rem);
  --step-2: clamp(1.44rem, 1.0703rem + 1.6432vw, 2.3438rem);
  --step-3: clamp(1.728rem, 1.2364rem + 2.1849vw, 2.9297rem);
  ...
  --space-xs: clamp(0.75rem, 0.5966rem + 0.6818vw, 1.125rem);
  --space-s: clamp(1rem, 0.7955rem + 0.9091vw, 1.5rem);
  --space-m: clamp(1.5rem, 1.1932rem + 1.3636vw, 2.25rem);
  --space-l: clamp(2rem, 1.5909rem + 1.8182vw, 3rem);
  --space-xl: clamp(3rem, 2.3864rem + 2.7273vw, 4.5rem);
}

You can then use these CSS variables as building blocks to make your design system fluid.

h1 {
font-size: var(--step-5);
}

h2 {
font-size: var(--step-4);
}

h3 {
font-size: var(--step-3);
}

...

.card {
  padding-block: var(--space-m);
  padding-inline: var(--space-s);
  gap-y: var(--space-2xs);
}

These CSS variables can also be used directly within Tailwind utility classes.

<article class="py-(--space-m) px-(--space-s) gap-y-(--space-2xs)">

This approach is robust, but has the downside of not being easy to tweak. For example to change screen widths or the font-size ranges, it requires to generate again all the values. Or setting a different growing ratio, on a specific screen size range, is not possible.

  • barvian/fluid-tailwind, this Tailwind plugin generates the fluid css values on the spot, by defining the ranges in the utility classes directly. All the utility classes that have a ~ in front of them are fluid. If no breakpoint is specified, the values interpolation takes place from the largest Tailwind breakpoint to the smallest.
<button class="bg-sky-500 ~px-4/8 ~py-2/4 ~text-sm/xl ...">Fluid button</button>

It can also define the starting and ending screen sizes directly in the utility classes, by using the variant modifier, always with a ~ in front of them.

<h1 class="~md/lg:~text-base/4xl">Quick increase</h1>

This solution is very flexible and a nice but bespoke syntax. The main downside is that this plugin is not supported in Tailwind v4.

# Plugin requirements

It's very convenient to use a simple symbol that conveys fluidity as the tilde ~, added in front of the utility classes that we already use.

But, it would be nice if instead of setting the range of the CSS values in a single class, as barvian/fluid-tailwind does, we could to extrapolate the range from the variant modifiers used in a single HTML element. Below is how a normal responsive styling looks like in Tailwind.

<div class="mt-6 md:mt-8 text-base md:text-lg xl:text-xl">

Making these values fluid could be as simple as adding a tilde in front of all the utility classes and variant modifiers that we want to make fluid. That would make it very easy to adopt it incrementally in existing projects, or to remove them if not required anymore, just search all these characters in a codebase and remove them.

<div class="~mt-6 ~md:mt-8 ~text-base ~md:text-lg ~xl:text-xl">

# Prototyping

The concept behind this proof-of-concept is to leverage CSS features to let the browser make most of the calculations, rather than the plugin.

# The clamping formula

The values needed to calculate the interpolation formula of two values within a screen-size range are four:

  • Min value
  • Max value
  • Min screen size
  • Max screen size

To set a fluid value in CSS, the standard approach is using the clamp() function.

font-size: clamp(minValue, preferred,  maxValue)

The preferred value is a CSS calc() function, where we calculate the ratio with which the CSS value scales, starting at the set minimum screen size.

font-size: clamp(minValue, calc(intercept + slope * 100vw),  maxValue)

And both the intercept and the slope can be calculated using the four values.

font-size: clamp(minValue, calc((minValue - ((maxValue - minValue)/(maxScreen - minScreen)) * minScreen) + ((maxValue - minValue)/(maxScreen - minScreen)) * 100vw),  maxValue)

# tailwindcss-fluid-variants v0.0.1

To make the browser calculate this style we can express the four values needed for the clamping formula as CSS custom properties.

Tailwind plugins can easily add CSS properties declarations to a utility class it generates. First, we use the matchVariant() function from the Tailwind Plugins APIs, to register custom dynamic variants, such as ~md:, ~lg:, ~xl:,etc. These new custom variants, instead of ouputting a single fixed value for a CSS property will output 3 custom properties and a CSS property with a clamp() value, this is an example.

.\~lg\:text-\[24px\] {
  --font-size-lg: 24px;
  --font-size-lg-int: 24; /* Also this because math */
  --font-size-lg-screen: 1024;

  font-size: clamp(...);
}

But to make the calculation happen, without knowing which utility classes will be eventually used in the same HTML element, each fluid utility class should evaluate any possible value for the CSS property it describes.

minValue = min(--font-size-sm, --font-size-md, --font-size-lg, ...)
maxValue = max(--font-size-sm, --font-size-md, --font-size-lg, ...)
minScreen = min(--font-size-sm-screen, --font-size-md-screen, --font-size-lg-screen, ...)
maxScreen = max(--font-size-sm-screen, --font-size-md-screen, --font-size-lg-screen, ...)

The final clamp formula with all the terms in place, will look like this.

.\~lg\:text-\[24px\] {
  --font-size-lg: 24px;
  --font-size-lg-int: 24;
  --font-size-lg-screen: 1024;

  font-size: clamp(min(var(--font-size-sm, 999999px), var(--font-size-md, 999999px), var(--font-size-lg, 999999px), var(--font-size-xl, 999999px), var(--font-size-2xl, 999999px)), calc(calc((min(var(--font-size-sm-int, 999999), var(--font-size-md-int, 999999), var(--font-size-lg-int, 999999), var(--font-size-xl-int, 999999), var(--font-size-2xl-int, 999999)) - calc((max(var(--font-size-sm-int, 0), var(--font-size-md-int, 0), var(--font-size-lg-int, 0), var(--font-size-xl-int, 0), var(--font-size-2xl-int, 0)) - min(var(--font-size-sm-int, 999999), var(--font-size-md-int, 999999), var(--font-size-lg-int, 999999), var(--font-size-xl-int, 999999), var(--font-size-2xl-int, 999999))) / (max(var(--font-size-sm-screen, 0), var(--font-size-md-screen, 0), var(--font-size-lg-screen, 0), var(--font-size-xl-screen, 0), var(--font-size-2xl-screen, 0)) - min(var(--font-size-sm-screen, 999999), var(--font-size-md-screen, 999999), var(--font-size-lg-screen, 999999), var(--font-size-xl-screen, 999999), var(--font-size-2xl-screen, 999999)))) * min(var(--font-size-sm-screen, 999999), var(--font-size-md-screen, 999999), var(--font-size-lg-screen, 999999), var(--font-size-xl-screen, 999999), var(--font-size-2xl-screen, 999999))) * 1px) + calc((max(var(--font-size-sm-int, 0), var(--font-size-md-int, 0), var(--font-size-lg-int, 0), var(--font-size-xl-int, 0), var(--font-size-2xl-int, 0)) - min(var(--font-size-sm-int, 999999), var(--font-size-md-int, 999999), var(--font-size-lg-int, 999999), var(--font-size-xl-int, 999999), var(--font-size-2xl-int, 999999))) / (max(var(--font-size-sm-screen, 0), var(--font-size-md-screen, 0), var(--font-size-lg-screen, 0), var(--font-size-xl-screen, 0), var(--font-size-2xl-screen, 0)) - min(var(--font-size-sm-screen, 999999), var(--font-size-md-screen, 999999), var(--font-size-lg-screen, 999999), var(--font-size-xl-screen, 999999), var(--font-size-2xl-screen, 999999)))) * 100vw), max(var(--font-size-sm, 0px), var(--font-size-md, 0px), var(--font-size-lg, 0px), var(--font-size-xl, 0px), var(--font-size-2xl, 0px)));
}

Unfortunately this will add more than 2kb of CSS for each utility class. Also this works, but only interpolating between a single pair of CSS values, within a single range of screen sizes.

<div class="~md:mt-6 ~lg:mt-10 ~xl:mt-8">

For example this will only calculate a clamp from 768px to 1280px, but the range value will be between 24px and 40px, because the value set in ~lg:mt-10 is the largest.

# tailwindcss-fluid-variants v0.0.2

It's actually possible to have the fluid values respect internal breakpoints when defined, but the plugin needs to generate all the possible breakpoints, whenever I have a fluid utility

.\~md\:text-\[18px\] {
  --f-s-md: 18px;
  --f-s-md-int: 18;
  --f-s-md-scr: 768;
}

@media (min-width: 640px) and (max-width: 767px) {
  .\~md\:text-\[18px\] {
  }
}

@media (min-width: 768px) and (max-width: 1023px) {
  .\~md\:text-\[18px\] {
  }
}

@media (min-width: 1024px) and (max-width: 1279px) {
  .\~md\:text-\[18px\] {
  }
}

...

And the CSS value needs to use var() order instead of min() and max() where each leg of the internal breakpoints has the CSS variables to look for the next breakpoint depending on the direction, with the fallback of each variable, being the variable for the next breakpoint.

To make it easier to understand the mechanism see how the values at the edges are calculated.

/* Min value at smaller breakpoint */
var(--f-s-sm)

/* Max value at smaller breakpoint */
var(--f-s-md, var(--f-s-lg, var(--f-s-xl, var(--f-s-2xl))))

A complete example for the range between two breakpoints would then look like this.

@media (min-width: 640px) and (max-width: 767px) {
  .\~md\:text-\[20px\] {
    font-size: clamp(var(--f-s-sm, 20px), calc(calc((var(--f-s-sm-int, 20) - calc((var(--f-s-md-int, var(--f-s-lg-int, var(--f-s-xl-int, var(--f-s-2xl-int, 20)))) - var(--f-s-sm-int, 20)) / (var(--f-s-md-scr, var(--f-s-lg-scr, var(--f-s-xl-scr, var(--f-s-2xl-scr, 640)))) - var(--f-s-sm-scr, 640))) * var(--f-s-sm-scr, 640)) * 1px) + calc((var(--f-s-md-int, var(--f-s-lg-int, var(--f-s-xl-int, var(--f-s-2xl-int, 20)))) - var(--f-s-sm-int, 20)) / (var(--f-s-md-scr, var(--f-s-lg-scr, var(--f-s-xl-scr, var(--f-s-2xl-scr, 640)))) - var(--f-s-sm-scr, 640))) * 100vw), var(--f-s-md, var(--f-s-lg, var(--f-s-xl, var(--f-s-2xl)))));
  }
}

Apart from this resulting in some massive CSS, I also encountered some issues where the outer values, for example sm, 2xl, are missing and there is no CSS variable fallback to calculate the clamping.

A possible fix would require a fragile solutions like applying the big formula only to elements that actually have the utility classes.

@media (min-width: 1024px) and (max-width: 1279) {
  :is(.\~md\:text-\[20px\][class*="~xl:text"],.\~md\:text-\[20px\][class*="~2xl:text"]) {
    font-size: clamp(...)
  }
}

This could be handled in the future with CSS if() function

if(--f-s-sm) ...

Eventually this works and I can say lessons where learned, but..

  • More than 4kb for each utility class
  • Fragile class selector
  • doesn't work with @apply
  • only tailwind v3 compatible
  • doesn't allow for custom ranges

# Resources