Powerful control over every class, breakpoint, and state.
No more tangled className strings. Grouped by breakpoint and state, readable at a glance, and fully typed.
npm install tailesspnpm add tailessyarn add tailessbun add tailessIn practice
This is the whole className
Three breakpoints, a dark mode with its own hover, a condition, a compound variant and the caller's className — one ss() call, checked by the compiler.
import { ss, match } from "tailess"; export function Card({ tone, wide, disabled, className }: CardProps) { return ( <div className={ss( { base: "rounded-lg border p-4", md: wide && "p-6", dark: { base: "border-neutral-800", hover: "border-neutral-700" }, "focus-visible": "ring-2 ring-offset-2", }, disabled && { base: "opacity-50 pointer-events-none" }, match(tone, { info: "bg-blue-50", danger: "bg-red-50" }), className, )} /> );}Before and after
The same className, twice
One ordinary button: three breakpoints, two states and a dark mode with its own hover. Both spellings produce the same class string at the same runtime cost — but one of them you can read, and the compiler checks every key.
<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"/><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", }, })}/>And one call is the whole className
Conditions, a caller's className and compound variants all go inside it. No wrapper helper, no second ss(). Keys are sorted inside each map; the arguments themselves are never reordered, which is what makes the last argument win exactly as it does in cn().
className={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,)}className={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,)}The API
Plain functions. No factory, no instance, no config object.
Every helper is a shape of ss(). Reach for the one that reads best where you are — they all produce the same kind of class string.
- ssneeds the pluginGroups, composition and nesting — the whole className in one call.
ss({ base: "flex", md: "text-2xl" }) - cnJoin and merge, nothing else. Drops straight into an existing cn() helper.
cn("px-2", isActive && "bg-blue-500") - responsiveneeds the pluginA base value plus mobile-first min-width overrides.
responsive("text-sm", { md: "text-lg" }) - until / betweenneeds the pluginMax-width ranges, for the styles that only apply below a breakpoint.
between("sm", "lg", "block") - onneeds the pluginOne state variant, or a whole stack of them.
on(["dark", "hover"], "bg-black") - data / arianeeds the pluginAttribute variants, for Radix, Ark, React Aria and friends.
data("state", "open", "opacity-100") - matchExhaustive lookup by a discriminant — a missing case will not compile.
match(size, { sm: "p-2", lg: "p-4" }) - withPrefixneeds the pluginThe escape hatch for any variant tailess does not model as a key.
withPrefix("has-[:checked]", "bg-blue-50")
See the full API reference for signatures, exported types and what “needs the plugin” means.
Quick start
Three steps, and the first one is an install command
Add one line to the config file you already have for Tailwind. That line is what lets Tailwind see the classes ss() builds at runtime — and if you forget it, tailess prints the exact fix instead of leaving you with a page that quietly lost its styles.
- 01
Install
One package, one runtime dependency. Use whichever package manager the project already has.
npm install tailesspnpm add tailessyarn add tailessbun add tailess - 02
Add the plugin — Vite
Order in the array does not matter — the hook is registered order: "pre", so it always runs before Tailwind.
vite.config.ts import { defineConfig } from "vite";import tailwindcss from "@tailwindcss/vite";import tailess from "tailess/vite"; export default defineConfig({ plugins: [tailwindcss(), tailess()],}); - 02
Or PostCSS — Next.js and friends
Listed before @tailwindcss/postcss. Works with Turbopack and webpack, in dev and in build.
postcss.config.mjs const config = { plugins: { "tailess/postcss": {}, "@tailwindcss/postcss": {}, },}; export default config; - 03
Use it
That is the whole setup. No config file, no CSS changes, nothing to commit.
Button.tsx import { ss } from "tailess"; <button className={ss({ base: "rounded-lg px-4 py-2", md: "px-6", hover: "bg-neutral-50", "focus-visible": "ring-2", })}/>
Your className already has structure. Make it visible.
MIT licensed, and the build plugin tells you when it is not wired up instead of leaving you with a page that quietly lost its styles.
npm install tailesspnpm add tailessyarn add tailessbun add tailess