Skip to content
<AG/>
← Blog

12 Dec 2025

How I Built fluid-typography: Designing a Zero-Config Tailwind Plugin

A walkthrough of building a Tailwind CSS plugin from scratch — the plugin API, generating clamp()-based utilities, and the API design decisions behind fluid-typography.

I wrote fluid-typography because I was tired of maintaining breakpoint ladders for type. This post is the other half of the story: not why fluid type is better (I covered that in the last post), but how the plugin itself is built — and the API-design decisions that make it zero-config.

The Tailwind plugin API in one minute

A Tailwind plugin is a function wrapped in plugin() from tailwindcss/plugin. Tailwind calls it with a set of helpers; the two that matter here are addUtilities (register static utility classes) and theme (read the user's config).

import plugin from "tailwindcss/plugin";

export default plugin(function ({ addUtilities, theme }) {
  addUtilities({
    ".text-monolith": { fontSize: "clamp(2.5rem, 1rem + 7vw, 6rem)" },
  });
});

That's the whole surface area. Everything fluid-typography does is generating the right clamp() strings and handing them to addUtilities.

Generating the clamp() from a min/max pair

The core is a pure function: given a min size, a max size, and the viewport window, return a clamp() string. The preferred (middle) term is a line through two points — (minVw, minPx) and (maxVw, maxPx) — expressed in rem + vw.

function clampFor(minPx, maxPx, minVw, maxVw) {
  const slope = (maxPx - minPx) / (maxVw - minVw);
  const intercept = minPx - slope * minVw;        // px at 0vw
  const preferred = `${round(intercept / 16)}rem + ${round(slope * 100)}vw`;
  return `clamp(${minPx / 16}rem, ${preferred}, ${maxPx / 16}rem)`;
}

Two decisions worth calling out:

  • rem, not px. Building the clamp in rem means it respects the user's browser font-size setting — an accessibility win that comes for free once the units are right.
  • Round the output. Raw floats produce 1.3333333rem noise in the compiled CSS. Rounding to a few decimals keeps the stylesheet readable with no visible difference.

Making it zero-config

Zero-config is a design stance, not a feature: the plugin must produce a useful default type scale with an empty call, fluidTypography(). So the default scale ships inside the plugin — a map of semantic names to [minPx, maxPx] pairs:

const DEFAULT_SCALES = {
  "monolith":    [40, 96],
  "display-xl":  [40, 60],
  "display-lg":  [32, 48],
  "body-xl":     [18, 20],
  "body-lg":     [16, 18],
  // ...
};

User config is then a merge, not a replacement. If you pass scales, your entries override matching keys and add new ones, but you keep the defaults you didn't touch. That's the difference between "zero-config with escape hatches" and "configure everything or get nothing".

const scales = { ...DEFAULT_SCALES, ...(options.scales ?? {}) };

Turning the scale into utilities

With the scale resolved, generating utilities is a map:

const utilities = Object.fromEntries(
  Object.entries(scales).map(([name, [min, max]]) => [
    `.text-${name}`,
    { fontSize: clampFor(min, max, minViewport, maxViewport) },
  ])
);
addUtilities(utilities);

Every text-* class the previous post used comes out of this single loop.

Typing the public API

The plugin is written in TypeScript and ships its types. The options interface is small on purpose — a big config surface is a maintenance liability and a sign the defaults are wrong.

export interface FluidTypographyOptions {
  minViewport?: number;                    // px, default 320
  maxViewport?: number;                    // px, default 1280
  scales?: Record<string, [number, number]>;
}

The [number, number] tuple is deliberate: it makes an illegal state (a scale with one or three values) unrepresentable, so the mistake is a type error, not a runtime surprise.

Lessons

  • A plugin is just a function that generates CSS. Once that clicked, the Tailwind API stopped being intimidating.
  • Defaults are the product. The hard part of a zero-config tool isn't the config path — it's choosing defaults good enough that most people never touch it.
  • Pure core, thin shell. The clamp() math is a pure function I can unit-test in isolation; the plugin wrapper just wires it to Tailwind. That separation made the whole thing easy to reason about.

Try it

I'm Ayan Ghosh, a full stack software engineer in Kolkata. If you're building a Tailwind plugin and get stuck on the API, reach out — happy to compare notes.