Skip to main content

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.

Before
<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.

After
<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().

Before
// 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,)}
After
// 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

If you ever forget to register the plugin, you get a console message naming the exact fix instead of a silently broken page. That guarantee is the whole reason this package exists.

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, one tailwind-merge pass whatever the shape.

Next steps

The whole setup is one import once the build plugin is in place:

Usage
import { ss } from "tailess";
  • Installation — requirements and the install command.
  • Setup — the one line of Vite, Next.js or PostCSS config.
  • The ss API — groups, composition and nesting.
  • How it works — what the plugin actually does to your build.