DEV Community

Caio Rodrigues
Caio Rodrigues

Posted on

responsive-tailwind: The Utility That Refuses To Build Your Class Names

Compile-time breakpoint validation, and the redundancy that makes it work

Every time someone sees the API for the first time, I get the same reaction:

responsive({
  base: "flex flex-col gap-4",
  md: "md:flex-row",
});
Enter fullscreen mode Exit fullscreen mode

"Why am I typing md twice?"

Fair question. The obvious version of this library writes the prefix for you: you pass md: "flex-row", it returns "md:flex-row", everyone goes home happy. I didn't build that, and not because I ran out of time. That version is broken by design, and the redundancy you're looking at is the entire reason this thing exists.

Tailwind Never Runs Your Code

This is the fact everything else hangs off, and it's the one people forget most often.

Tailwind doesn't analyze your program. It doesn't have a type checker, it doesn't evaluate expressions, it doesn't know what your functions return. At build time it reads your source files as plain text and looks for things that look like class names. Whatever it finds, it generates CSS for. Whatever it doesn't find, doesn't exist.

Which means this compiles, runs, passes review, and produces nothing:

const bp = "md";
<div className={`${bp}:flex-row`} />
// Tailwind scanned your file and saw "${bp}:flex-row".
// No CSS rule was generated. The class is in the DOM, styling nothing.
Enter fullscreen mode Exit fullscreen mode

You already know this rule. You've probably hit it with dynamic color classes (`bg-${color}-500`) and learned to write the full string out. But the moment someone hands you a helper that builds class names, the rule quietly stops applying — not because the helper is smarter, but because the string construction moved one file away where you can't see it. A helper that turns md: "flex-row" into "md:flex-row" at runtime has the exact same problem as the template literal above. Tailwind never sees md:flex-row anywhere in your source, so md:flex-row never lands in your stylesheet.

So responsive-tailwind doesn't construct anything. Every class you want in your CSS is written out, in full, in a place the scanner can read it. The object key doesn't generate the prefix — it asserts what the prefix must be, and TypeScript enforces it.

You still type it twice. The compiler now cares if the two copies disagree.

What It Actually Does

Three jobs, none of them glamorous:

  1. Groups your classes by breakpoint so a 40-class className stops being one unreadable line.
  2. Validates at compile time that every token under md really starts with md:.
  3. Merges the result through tailwind-merge so conflicting utilities resolve instead of stacking.
npm install responsive-tailwind
Enter fullscreen mode Exit fullscreen mode
import { responsive } from "responsive-tailwind";

export function Card() {
  return (
    <div
      className={responsive({
        base: "flex flex-col gap-4 p-4",
        md: "md:flex-row",
        lg: "lg:p-8",
      })}
    >
      <div className="flex-1">Content</div>
      <div className="flex-1">Sidebar</div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Output:

flex flex-col gap-4 p-4 md:flex-row lg:p-8
Enter fullscreen mode Exit fullscreen mode

Same string you'd have written by hand. That's the point — the output is boring on purpose. What changed is that a typo in it is now a build error instead of a component that silently doesn't respond at 768px.

Arbitrary breakpoints work the same way:

responsive({
  base: "grid",
  "min-[900px]": "min-[900px]:grid-cols-3",
  "max-[500px]": "max-[500px]:hidden",
});
Enter fullscreen mode Exit fullscreen mode

Where The Type Safety Comes From

No plugin, no build step, no codegen. It's one recursive conditional type, and it's small enough to read in full:

type ValidatePrefixedTokens<Prefix extends string, S extends string> =
  S extends `${infer Head} ${infer Tail}`
    ? Head extends `${Prefix}:${string}`
      ? ValidatePrefixedTokens<Prefix, Tail>
      : InvalidBreakpointPrefix<Prefix, Head>
    : S extends ""
      ? true
      : S extends `${Prefix}:${string}`
        ? true
        : InvalidBreakpointPrefix<Prefix, S>;
Enter fullscreen mode Exit fullscreen mode

It splits the string on the first space, checks the head against the prefix, recurses on the tail. Token by token, at compile time, on the literal type of the string you wrote.

When a token fails, the failure branch doesn't return never or false — it returns a template literal type that spells out what went wrong. So the error message is a type:

responsive({ md: "flex-row" });
Enter fullscreen mode Exit fullscreen mode
error TS2322: Type '"flex-row"' is not assignable to
type '"Class 'flex-row' in md must start with 'md:'"'.
Enter fullscreen mode Exit fullscreen mode

It rides inside an assignability error, so there's some TypeScript noise wrapped around it. But the sentence you actually need to read is right there in the message, naming the offending token and the prefix it should have had. Hovering the property in your editor shows the same thing.

Arbitrary breakpoints get checked against their own key, so a mismatch you'd never spot by eye gets caught:

responsive({ "min-[900px]": "min-[800px]:grid-cols-3" });
// Class 'min-[800px]:grid-cols-3' in min-[900px] must start with 'min-[900px]:'
Enter fullscreen mode Exit fullscreen mode

One note on the recursion: validating string literals token by token is exactly the kind of type-level work that used to make people nervous about editor responsiveness. I built and tested this against TypeScript 7, where that calculus is a lot friendlier than it used to be. Worth mentioning since it shaped how comfortable I was leaning on recursive types at all.

tailwind-merge Is Doing Real Work Here

Easy to write this off as a dependency I added for the badge. It isn't.

responsive({ base: "p-2 p-4" });
// → "p-4"

responsive({ md: "md:gap-4 md:gap-8" });
// → "md:gap-8"
Enter fullscreen mode Exit fullscreen mode

Conflicts collapse to the last one, which is what you want when classes get composed from a base and an override. But notice what doesn't collapse:

responsive({ base: "flex-col", md: "md:flex-row" });
// → "flex-col md:flex-row"
Enter fullscreen mode Exit fullscreen mode

flex-col and md:flex-row aren't a conflict — they're different variants, and killing one would break the whole idea of responsive design. tailwind-merge understands the variant axis, which is precisely why I didn't hand-roll a dedupe.

What Doesn't Change

The list I'd want to read first, if someone handed me this library:

  • Your CSS output. Same utilities in, same stylesheet out. Nothing is generated, so nothing new can appear.
  • Your tailwind.config. No plugin to register, no preset, no PostCSS entry.
  • Tailwind's scanner. It keeps working exactly as it always did, because every class is still a literal string in your source. That's the whole design constraint.
  • Your runtime. The type layer erases at build time and costs nothing. The only thing executing in production is one twMerge call.
  • Your existing components. responsive() returns a string. Drop it into one className and leave everything else alone.

This Is Not clsx, And It's Not cva

These get lumped together and they solve different axes:

  • clsx / classnames answer "which classes apply right now?" — conditional joining based on props and state.
  • cva / tailwind-variants answer "what are this component's variants?" — a design-system-shaped API over size, intent, and so on.
  • responsive-tailwind answers "are my breakpoint classes actually correct?" — one axis, checked at compile time.

They compose fine, because all three are just producing strings:

<div className={clsx(
  responsive({ base: "flex flex-col", md: "md:flex-row" }),
  isActive && "ring-2 ring-blue-500"
)} />
Enter fullscreen mode Exit fullscreen mode

If you already have a variant system you like, keep it. This slots underneath it.

Honestly: What v1 Doesn't Do Yet

The asterisks, since a features list with no limitations section is marketing, not documentation.

Breakpoint keys are a fixed set. sm, md, lg, xl, 2xl, plus min-[...] and max-[...]. If you've defined custom named screens in your Tailwind config, the type doesn't know about them — use the arbitrary syntax for now.

Only breakpoints are validated. dark:, hover:, group-* and friends aren't breakpoint keys, so they pass through untouched. Stacking them on a validated breakpoint works exactly as you'd expect (md: "md:hover:bg-blue-500" is valid), they just aren't the axis this library checks.

The token splitter wants single spaces. The recursion splits on one space at a time, so a double space or a line break inside a breakpoint value confuses it and you get an error naming an empty token. Keep breakpoint values on one line with single spaces and you'll never see it. It's on my list.

Non-literal strings are skipped by design. If you pass a value typed as string rather than a literal, there's nothing for the compiler to inspect, so it's allowed through. Useful as an escape hatch, worth knowing it's a hole.

Validation is compile-time only. Nothing is checked at runtime. If you're not type-checking your project, this library does nothing for you but call twMerge.

A Guardrail, Not An Abstraction

The reason I keep coming back to that "why twice?" question is that it's the whole thesis in miniature.

An abstraction would hide the prefix and hand you a cleaner-looking API that quietly disagrees with how Tailwind actually works. A guardrail leaves the prefix exactly where the scanner can see it and just refuses to let you get it wrong. The first one feels better in a README. The second one is the one that still works when your component doesn't reflow at 768px in production and you have twenty minutes to figure out why.

Small library. Opinionated about one thing. That's the pitch.

Issues and API feedback welcome — v1.0.0 is the smallest thing I thought was worth publishing, and the shape of v1.1 depends a lot on what people actually hit.

Top comments (0)