Introduction
What tailess is, the problem it solves, why one ss() call is the whole className, and why a build plugin is what makes runtime-built variants get CSS.
The problem
A long Tailwind className is one flat string with base classes, breakpoints and states all interleaved. The button below is an ordinary one — three breakpoints, two states and a dark mode with its own hover — and already there is no structure to read, nothing checks the variant names, and a typo stays invisible until you notice the missing style in the browser.
<button className="flex gap-2 rounded-lg border px-3 py-1.5 text-sm sm:px-4 md:gap-3 md:rounded-xl md:px-5 md:text-base lg:px-6 hover:bg-neutral-50 focus-visible:outline-2 dark:border-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-900"/>tailess lets you write the same thing as an object. Same output, same runtime cost profile as any clsx + tailwind-merge setup — but the structure is visible, and the compiler checks it.
<button className={ss({ base: "flex gap-2 rounded-lg border px-3 py-1.5 text-sm", sm: "px-4", md: "gap-3 rounded-xl px-5 text-base", lg: "px-6", hover: "bg-neutral-50", "focus-visible": "outline-2", dark: { base: "border-neutral-800 text-neutral-100", hover: "bg-neutral-900", }, })}/>One call is the whole className
ss is variadic. Conditions, a caller's className and compound variants all go inside it — no wrapper helper, no second ss().
// a wrapper helper, and ss() again for every conditionclassName={cn( ss({ base: "rounded-lg border p-4", md: "p-6" }), ss({ dark: "border-neutral-800" }), isDisabled && ss({ base: "opacity-50", sm: "bg-red-500" }), className,)}// one call — conditions, nesting and the caller's className all go inside itclassName={ss( { base: "rounded-lg border p-4", md: "p-6", dark: { base: "border-neutral-800", hover: "border-neutral-700" }, }, isDisabled && { base: "opacity-50", sm: "bg-red-500" }, className,)}ss is a strict superset of a cn() helper: hand it plain strings and it is cn.
Not just a formatting trick
Building md: at runtime is easy. Making Tailwind emit CSSfor it is the hard part, and it is where every hand-rolled version of this idea quietly fails: a prefix joined to a utility while your code runs is never a literal string in your source, so Tailwind's scan never finds it and no rule is emitted.
tailess ships a one-line build plugin that closes that gap. How it works is the mechanism in full — what the plugin hands Tailwind, and the three details that let a brand-new class appear without restarting the dev server.
No silent failures
What you get
- Typed against Tailwind itself — 149 keys, every one verified against the real Tailwind compiler in CI.
- One line of setup — a Vite or PostCSS plugin. No config file, no CSS changes, nothing to commit.
- No silent failures — a missing plugin tells you, instead of dropping styles.
- Instant in dev — add a class and it appears without restarting; delete it and it stops being emitted.
- Small — 2.7 kB of its own code, one dependency, ESM + CJS, tree-shakeable.
- Fast —
ss()with three groups costs ~385 ns, onetailwind-mergepass whatever the shape.
Next steps
The whole setup is one import once the build plugin is in place:
import { ss } from "tailess";- Installation — requirements and the install command.
- Setup — the one line of Vite, Next.js or PostCSS config.
- The
ssAPI — groups, composition and nesting. - How it works — what the plugin actually does to your build.