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, notpx. Building the clamp inremmeans 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.3333333remnoise 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
- npm: npmjs.com/package/fluid-typography
- Docs & live playground: fluid-typography.ayanghosh.in
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.