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",
});
"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.
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:
- Groups your classes by breakpoint so a 40-class
classNamestops being one unreadable line. - Validates at compile time that every token under
mdreally starts withmd:. - Merges the result through
tailwind-mergeso conflicting utilities resolve instead of stacking.
npm install responsive-tailwind
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>
);
}
Output:
flex flex-col gap-4 p-4 md:flex-row lg:p-8
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",
});
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>;
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" });
error TS2322: Type '"flex-row"' is not assignable to
type '"Class 'flex-row' in md must start with 'md:'"'.
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]:'
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"
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"
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
twMergecall. -
Your existing components.
responsive()returns a string. Drop it into oneclassNameand leave everything else alone.
This Is Not clsx, And It's Not cva
These get lumped together and they solve different axes:
-
clsx/classnamesanswer "which classes apply right now?" — conditional joining based on props and state. -
cva/tailwind-variantsanswer "what are this component's variants?" — a design-system-shaped API over size, intent, and so on. -
responsive-tailwindanswers "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"
)} />
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.
-
npm:
responsive-tailwind - GitHub: babyreptile/responsive-tailwind
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)