npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

tailess

v0.13.0

Published

Write Tailwind classes as a readable object, grouped by breakpoint and state — fully typed, and wired into Tailwind so it actually works.

Readme


A long Tailwind className is one flat string with base classes, breakpoints and states all interleaved. tailess lets you write the same thing as an object — every key autocompleted, every typo a compile error.

// ❌ one string, everything jumbled together
<div className="text-xl flex sm:block md:text-2xl hover:opacity-100 dark:bg-black" />

// ✅ grouped, readable, typed
<div className={ss({
  base:  "text-xl flex",
  sm:    "block",
  md:    "text-2xl",
  hover: "opacity-100",
  dark:  "bg-black",
})} />

Same output, same runtime cost profile as any clsx + tailwind-merge setup — but the structure is visible, and the compiler checks it.

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():

// ❌ a wrapper, and ss() again for every condition
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,
)}

// ✅ one call
className={ss(
  {
    base: "rounded-lg border p-4",
    md:   "p-6",
    dark: "border-neutral-800",
  },
  isDisabled && { base: "opacity-50", sm: "bg-red-500" },
  className,
)}

ss is a superset of a cn() helper for strings and arrays: hand it those and it is cn. A bare clsx dictionary is the one exception — to ss an object is a bucket map — so wrap one in an array: ss([{ "font-bold": isActive }]).

Contents


Features

🎯  Typed against Tailwind itself

305 keys, every one verified against the real Tailwind compiler in CI.

🔌  One plugin, no config

A Vite or PostCSS plugin. No config file, no CSS changes, nothing to commit.

🧯  Tells you when it isn't wired up

A dev-time check warns if the build plugin is missing, naming the line of config to add.

♻️  Instant in dev

Add a class and it appears without restarting; delete it and it stops being emitted.

🔍  Provable

tailess check compiles your project and fails the build if a class has no CSS behind it.

⚡  Fast

ss() with three groups costs ~244 ns, one tailwind-merge pass whatever the shape.


Requirements

| | | | --- | --- | | Tailwind CSS | v4.1 or later — the plugins inject @source inline(…), which 4.0 cannot parse; v3 is not supported | | Node | 20.19+ for the build plugins and the CLI (what engines enforces, and what Vite 8 needs); the runtime has no Node dependency | | Bundler | anything using @tailwindcss/vite or @tailwindcss/postcss — anything else via tailess emit | | TypeScript | 5.0+ for the types, which use const type parameters; 4.9 cannot read them, even with skipLibCheck. Plain JavaScript needs nothing | | Dependencies | one — tailwind-merge |

Install

npm install tailess

A runnable app is in examples/vite-react — Vite + React, every class built at runtime, with npm run verify wired to the gate. CI builds it on every push, and asserts the gate goes red when the plugin is removed.

[!TIP] Already have a cn() helper? ss is a superset of it — the same call with strings and arrays behaves identically, so you can swap one file at a time. A bare clsx dictionary is the exception: ss reads an object as a bucket map, so cn({ "font-bold": on }) becomes ss([{ "font-bold": on }]).

Setup

Add the plugin to the config file you already have for Tailwind. There is no tailess.config, nothing to add to your CSS, and no generated file to commit.

npx tailess init          # shows the edit it would make
npx tailess init --write  # makes it
npx tailess doctor        # says whether the plugin is wired up: exit 1 if not, 2 if no config

init reads your project, picks the right integration, and writes the edit — after printing it. doctor is the same reading without the edit, and is worth a CI step: a missing plugin is the one failure nothing else reports, because the build succeeds and the class attributes are correct while nothing on the page has styles.

Both read the file your build loads: vite.config.* in Vite's own order, the vite key of an Astro, Nuxt or SolidStart config, and a PostCSS config wherever postcss-load-config looks for one — package.json and every .postcssrc spelling included. They follow a plugin list into a local preset (plugins: sharedPlugins() from ./vite.shared), and a PostCSS entry counts only when it is listed, not false, and ahead of @tailwindcss/postcss. init edits vite.config and postcss.config files; for a framework's config or a JSON or YAML one it prints the line to add instead.

Or do it by hand — an import and a plugins entry in Vite, one entry in PostCSS.

Vite

React, Vue, Svelte, Solid, Qwik, Astro — anything on Vite.

// vite.config.ts
import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";
import tailess from "tailess/vite";

export default defineConfig({
  plugins: [tailwindcss(), tailess()],
});

Order in the array doesn't matter — the hook is registered order: "pre", so it always runs before Tailwind wherever you put it. A CommonJS config works the same way: require("tailess/vite") is the plugin itself. An @import "tailwindcss" written in a Vue or Svelte <style> block, or an inline <style> in index.html, is handled like a .css file — though tailess check only reads stylesheet files, so give it one with --css or it exits 2 with nothing checked.

Next.js

Add it to the postcss.config.mjs that create-next-app already generated, before @tailwindcss/postcss:

// postcss.config.mjs
const config = {
  plugins: {
    "tailess/postcss": {},
    "@tailwindcss/postcss": {},
  },
};

export default config;

Works with Turbopack and webpack, in dev and build.

Other PostCSS setups

The same postcss.config.* works for Remix, Astro-with-PostCSS, Nuxt, the PostCSS CLI, and anything else in that family — including the array form:

module.exports = {
  plugins: [require("tailess/postcss")(), require("@tailwindcss/postcss")()],
};

[!IMPORTANT] On Vite, use tailess/vite — not tailess/postcss. @tailwindcss/vite compiles CSS in a pre transform, which runs before Vite's PostCSS stage, so a PostCSS plugin can never reach it. (If your Vite project gets Tailwind through postcss.config.* rather than @tailwindcss/vite, then the PostCSS plugin is the right one.)

That's the whole setup:

import { ss } from "tailess";

Sorting classes

tailess sorts your keys — base, breakpoints, max-*, containers, states — but not the classes inside them. For that, point Tailwind's own formatter at the helpers — in .prettierrc.json:

{
  "plugins": ["prettier-plugin-tailwindcss"],
  "tailwindStylesheet": "./src/index.css",
  "tailwindFunctions": [
    "ss", "cn", "variants", "responsive", "match",
    "on", "until", "between", "data", "aria",
    "group", "peer", "container", "withPrefix"
  ]
}

tailwindStylesheet is the CSS entry holding your @import "tailwindcss" — on Next.js usually ./app/globals.css. Leave it out and anything from your own @theme or @utility is treated as an unknown class and sorted to the front.

[!WARNING] Do not add supports, notSupports, has, notHas, inside or the nth* helpers to that list. The plugin sorts every string argument of a listed function, and the first argument of those is a selector or a feature query, not a class list. It reorders that too: has("table [data-open]", …) is rewritten to has("[data-open] table", …) — Tailwind knows table as a utility and [data-open] as unknown, so it moves them — and the selector now means the opposite of what it said. Format-on-save does it silently. Their class arguments go unsorted; that is the trade, and it is the right way round.

// before
ss({ base: "text-sm p-4 flex items-center", md: "gap-2 p-8 grid" })

// after
ss({ base: "flex items-center p-4 text-sm", md: "grid gap-2 p-8" })

Each string is sorted on its own. Separate arguments are never reordered, so a trailing className still wins.

[!NOTE] Two conflicting utilities in one string can be reordered — "p-4 p-2" becomes "p-2 p-4", and tailwind-merge then keeps p-4 where it kept p-2. Write the override as its own argument instead, which nothing reorders: ss({ base: "p-4" }, "p-2").

Editor setup

Tailwind's VS Code extension recognises class and className out of the box — not the strings inside ss({ … }). Without this, moving a className into a bucket costs you class-name completion, colour swatches, hover previews and the unknown-class warning, which is the only thing catching a typo inside a class string. tailess types the keys; this is what types the values.

.vscode/settings.json:

{
  "tailwindCSS.classFunctions": [
    "ss", "cn", "variants", "responsive", "match",
    "on", "until", "between", "data", "aria",
    "group", "peer", "container", "withPrefix",
    "supports", "notSupports", "has", "notHas", "inside",
    "nth", "nthLast", "nthOfType", "nthLastOfType"
  ]
}

Unlike the prettier list above, this one is safe to give every helper: the extension reads, it never rewrites. Commit the file so the whole team gets it.

If completions do not appear in some shape, tailwindCSS.experimental.classRegex is the escape hatch — it matches on surrounding text where classFunctions matches only on the function name.


API

Every helper is a plain function. No factory, no instance, no config object.

import {
  ss, cn, responsive, on, until, between,
  data, aria, supports, notSupports, match, withPrefix, vars,
  group, peer, container, has, notHas, inside,
  nth, nthLast, nthOfType, nthLastOfType, variants,
} from "tailess";

| | | needs the plugin | | --- | --- | :---: | | ss | groups, composition, nesting — the whole className | ✅ | | cn | join and merge, nothing else | — | | responsive | a base plus min-width overrides | ✅ | | until / between | max-width ranges | ✅ | | on | one state variant, or a stack of them | ✅ | | data / aria | attribute variants, for headless UI | ✅ | | supports / notSupports | feature queries, spaces escaped for you | ✅ | | group / peer / container | the named group, peer and container variants | ✅ | | has / notHas / inside | has-[…] and in-[…] from a selector | ✅ | | nth and its three siblings | :nth-child() and friends, by position or expression | ✅ | | match | exhaustive lookup by a discriminant | — | | variants | a component recipe, with ss maps as options | ✅ | | withPrefix | any variant tailess doesn't model | ✅ | | vars | custom properties, for values no class can hold | — |

"Needs the plugin" means the helper builds a variant prefix at runtime, so Tailwind never sees the finished class in your source. cn and match only ever pass through classes you already wrote as literals, and vars produces no class at all.

ss — group by breakpoint and state

The main event. base holds classes with no further prefix; every other key is a breakpoint, a max-* range, a container query, or a state variant.

ss({ base: "text-xl flex", sm: "block", md: "text-2xl" });
// → "text-xl flex sm:block md:text-2xl"

ss({ base: "grid", "max-md": "gap-2", "group-hover": "underline" });
// → "grid max-md:gap-2 group-hover:underline"

// Sized by the nearest `@container` ancestor rather than the viewport:
ss({ base: "grid", "@md": "grid-cols-2", "@max-sm": "hidden" });
// → "grid @md:grid-cols-2 @max-sm:hidden"

ss({ base: "opacity-100", "not-hover": "opacity-70", "not-dark": "text-black" });
// → "opacity-100 not-hover:opacity-70 not-dark:text-black"

Keys are emitted base → breakpoints mobile-first → max-* largest-first → @ containers smallest-first → @max-* largest-first → states → the keys you declared in configure({ keys }), in the order given → anything undeclared, whatever order you wrote them in, and the result runs through cn. Stable order is what keeps tailwind-merge's "last one wins" predictable.

Values are clsx-style, so conditions go inline. A falsy value drops the whole group, prefix included:

ss({ base: "text-sm", md: isActive && "text-2xl" });
// isActive === false → "text-sm"

Many arguments, one call

ss is variadic, and an argument is anything a bucket accepts: another map, a class string, a clsx array, or a condition that produces one. This is what a className looks like in practice — and why nothing needs to wrap it:

ss(
  { base: "rounded-lg border p-4", md: "p-6" },
  isDisabled && { base: "opacity-50", sm: "bg-red-500" },
  match(tone, { info: "bg-blue-50", danger: "bg-red-50" }),
  className,
);

Keys are sorted inside each map; the arguments themselves are never reordered. That is what makes the last argument win, exactly as it does in cn:

ss({ base: "p-4", md: "p-6" }, "md:p-10");   // → "p-4 md:p-10"
ss({ base: "p-4" }, { base: "p-8" });        // → "p-8"

Sorting a bare string into the base bucket instead would put a caller's className="md:p-10" ahead of your own md:p-6 and quietly lose to it. It doesn't.

Given only strings and arrays, ss is cn:

ss("px-2 py-1", isActive && "bg-blue-500", "px-4");  // → "py-1 bg-blue-500 px-4"

Nested groups, for compound variants

A bucket's value can be another map, which stacks the prefixes. Each breakpoint gets its own group, with the same keys and the same rules:

ss({
  base: "text-black p-4",
  md: {
    base:     "p-6",     // → md:p-6
    hover:    "p-8",     // → md:hover:p-8
    "max-lg": "grid",    // → md:max-lg:grid
  },
  dark: {
    base:  "text-white",       // → dark:text-white
    hover: "text-blue-300",    // → dark:hover:text-blue-300
  },
});

md: "p-6" and md: { base: "p-6" } mean the same thing, so nothing has to change to start nesting. A falsy nested bucket drops, prefix included, like any other.

[!NOTE] A plain object is always a nested map, and an array is always clsx classes. Nothing is decided by looking at your key names, so the same source always means the same thing. Put a clsx dictionary inside an array — md: [{ "text-lg": cond }] — where there is nothing to confuse it with.

cn — compose and merge

clsx-style conditional joining, then tailwind-merge for conflict resolution. ss is a superset of it for strings and arrays (a bare dictionary goes in an array there), so reach for cn when there are no breakpoints or states in sight and you'd rather say so.

cn("px-2 py-1", isActive && "bg-blue-500", "px-4");
// → "py-1 bg-blue-500 px-4"   (px-2 dropped in favour of px-4)

Its argument type is clsx's own, so it accepts any object — a function included. A recipe handed over uncalled, cn(button, className), type-checks and contributes nothing; call it — button({ tone }, className) takes the extra classes itself. ss refuses a function outright.

responsive — mobile-first

responsive("text-sm", { md: "text-lg", xl: "text-2xl" });
// → "text-sm md:text-lg xl:text-2xl"

until / between — max-width ranges

until("md", "hidden");          // → "max-md:hidden"      (below md)
between("sm", "lg", "block");   // → "sm:max-lg:block"    (sm up to, not incl., lg)

on — state variants

on("hover", "bg-blue-600 text-white");  // → "hover:bg-blue-600 hover:text-white"
on(["dark", "hover"], "bg-black");      // → "dark:hover:bg-black"

Each of these is a shape of ss

Now that ss is variadic and nests, every helper above is one of its forms. They are staying — each reads better on its own, and an unused one costs nothing — but if you'd rather write everything one way, here is the translation:

| helper | the ss form | | --- | --- | | responsive("text-sm", { md: "text-lg" }) | ss({ base: "text-sm", md: "text-lg" }) | | on("hover", x) | ss({ hover: x }) | | on(["dark", "hover"], x) | ss({ dark: { hover: x } }) | | until("md", x) | ss({ "max-md": x }) | | between("sm", "lg", x) | ss({ sm: { "max-lg": x } }) | | cn(a, cond && b) | ss(a, cond && b) |

data / aria — attribute variants

For headless UI libraries (Radix, Ark, React Aria).

data("state", "open", "opacity-100");           // → "data-[state=open]:opacity-100"
data("disabled", null, "pointer-events-none");  // → "data-[disabled]:pointer-events-none"
aria("expanded", "rotate-180");                 // → "aria-expanded:rotate-180"

A value containing a space can't appear in a class name, so write it Tailwind's way — with _, which Tailwind reads back as a space:

data("state", "half_open", "opacity-50");  // matches data-state="half open"

Passing a literal space warns in development rather than silently producing a class that matches nothing.

supports / notSupports — feature queries

Apply classes only when the browser understands a CSS feature. Write the query the way CSS spells it; the space is escaped for you.

supports("display: grid", "grid");     // → "supports-[display:_grid]:grid"
supports("gap", "gap-4");              // → "supports-[gap]:gap-4"
notSupports("display: grid", "flex");  // → "not-supports-[display:_grid]:flex"

A query with no : tests the property itself, so supports("gap", …) asks whether gap is understood at all.

Combining queries needs every term in its own parentheses — supports("(display:grid) and (gap:1rem)", …). Without them the whole string becomes a single condition that is false in every browser, so a missing pair warns in development. A combined query cannot be negated, because @supports not (a) and (b) is not valid CSS; write supports("not ((a) and (b))", …) instead.

group / peer / container — named variants

The unnamed forms are already keys: group-hover and peer-checked are state variants, @md and @max-md are container queries. They reach the nearest group, peer or container — which stops being enough the moment those nest. Name the parent and these target that one.

group("row", "hover", "underline");          // → "group-hover/row:underline"
peer("email", "invalid", "text-red-600");    // → "peer-invalid/email:text-red-600"
container("sidebar", "@md", "grid-cols-2");  // → "@md/sidebar:grid-cols-2"
container("main", "@max-lg", "hidden");      // → "@max-lg/main:hidden"

The name goes on the element you are naming, with the same / spelling:

<li className="group/row">
  <span className={group("row", "hover", "underline")} />
</li>

<aside className="@container/sidebar">
  <div className={container("sidebar", "@md", "grid-cols-2")} />
</aside>

A group or peer name may contain letters, digits, - and _. Anything else — a space, a /, a :, or an empty name — produces a class Tailwind generates no rule for.

A container name is stricter, because Tailwind also writes it into container-name: and the @container prelude, where CSS requires an identifier: it cannot start with a digit, and cannot be none, and, or, not or a CSS-wide keyword. container("2xl-panel", …) compiles to CSS the browser then discards entirely.

Both are checked in development, so a name that cannot work says so.

has / notHas / inside — selector variants

For a plain state these are keys, not calls: has-checked and in-focus cover the same 36 states group-* and peer-* do, so write those in ss directly. These helpers are for the other form — an arbitrary selector.

has(":checked", "bg-blue-50");        // → "has-[:checked]:bg-blue-50"
has("> img", "p-0");                  // → "has-[>_img]:p-0"
has("input[type=text]", "ring-2");    // → "has-[input[type=text]]:ring-2"
notHas(":checked", "opacity-50");     // → "not-has-[:checked]:opacity-50"
inside(".dark", "text-white");        // → "in-[.dark]:text-white"

Write the selector the way CSS spells it; the space is escaped for you. inside is named that way because in is a reserved word.

Mind which negation you want. notHas(":checked", …) builds not-has-[:checked]:, which is :not(:has(…)) — no checked descendant. Tailwind also accepts has-not-[:checked], which is :has(:not(…)) — a descendant that is not checked. Both compile and they mean different things; for the second, write has(":not(:checked)", …).

nth — position variants

A number is a position, counting from 1. A string is an An+B expression or a keyword, and goes in brackets — spaces and all, since they are escaped for you.

nth(3, "bg-neutral-50");        // → "nth-3:bg-neutral-50"
nth("3n + 1", "border-t");      // → "nth-[3n_+_1]:border-t"
nth("-n+3", "font-bold");       // → "nth-[-n+3]:font-bold"
nthLast(1, "border-b-0");       // → "nth-last-1:border-b-0"
nthOfType("odd", "bg-white");   // → "nth-of-type-[odd]:bg-white"
nthLastOfType(1, "mb-0");       // → "nth-last-of-type-1:mb-0"

odd and even are their own variants and already keys, so reach for those directly: ss({ odd: "bg-neutral-50" }).

:nth-child() counts from 1, so nth(0, …) builds a class that can never match — that, a fraction, and a negative number all warn in development.

match — exhaustive variant selection

Map a discriminant to a class value. Every case must be covered, so a missing one is a compile error; extra cases are allowed.

function Button({ size }: { size: "sm" | "md" | "lg" }) {
  const sizing = match(size, {
    sm: "px-2 py-1 text-sm",
    md: "px-3 py-2 text-base",
    lg: "px-4 py-3 text-lg",   // omit one and it won't compile
  });
}

match(tone, { primary: "bg-blue-600", danger: "bg-red-600" }, "bg-gray-200");
// unknown tone at runtime → the fallback

Every class here is already a literal, so match needs no build integration.

variants — component recipes

A component's className built from typed variants, with defaults and compound rules. The familiar shape, with one difference: every value is an ss argument, so a variant option can be an ss map and carry breakpoints and states of its own.

const button = variants({
  base: { base: "rounded font-medium", hover: "brightness-110" },
  variants: {
    tone: { primary: "bg-blue-600", danger: "bg-red-600" },
    size: { sm: "text-sm px-2", lg: { base: "text-lg px-4", md: "px-6" } },
  },
  compound: [{ tone: "danger", size: "lg", class: "ring-2" }],
  defaults: { tone: "primary", size: "sm" },
});

button();                              // → the defaults
button({ size: "lg" });                // → "… text-lg px-4 md:px-6"
button({ tone: "danger" }, className); // extra arguments, exactly like cn

Both halves of { size: "lg" } are checked: a variant you did not declare and an option that variant does not have are each a compile error.

Emission is base, then each variant in the order you declared it, then the compound rules, then whatever the caller passed — so a trailing className still wins, and the same props always produce the same string. The whole thing ends in ss, so conflicts merge once, across all of it.

{ size: undefined } leaves the default in place, which is what a component writes when it forwards an optional prop it did not receive.

An extra argument is a ClassArg — a class string, an array, a falsy value — not an ss map. The build reads your recipe and your ss(…) calls, never the calls of the component the recipe builds, so a map there would put md:w-auto on the element with no rule behind it. Wrap it instead; the ss call is literal, so the build reads it where it is written:

button({ tone: "danger" }, ss({ md: "w-auto" }));  // not button({…}, { md: "w-auto" })

VariantProps reads the prop type back off the component, so a component declares its own props against the recipe rather than restating it:

type ButtonProps = VariantProps<typeof button> & { children: ReactNode };

const Button = ({ tone, size, children }: ButtonProps) => (
  <button className={button({ tone, size })}>{children}</button>
);

slots — a component with named parts

A Dialog is root, overlay, panel, title and close. Declare the parts instead of base, and every option says what it adds to each one. The call returns a class string per part:

const card = variants({
  slots: {
    root:  { base: "rounded-lg border", dark: "border-neutral-800" },
    title: "font-semibold",
    body:  "text-sm",
  },
  variants: {
    size: {
      sm: { root: "p-3", title: "text-base" },
      lg: { root: { base: "p-5", md: "p-8" }, title: "text-xl" },
    },
    tone: { danger: { root: "border-red-500", title: "text-red-700" } },
  },
  compound: [{ size: "lg", tone: "danger", class: { root: "ring-2" } }],
  defaults: { size: "sm" },
});

const { root, title, body } = card({ size: "lg" });

A slot's value is an SsArg like anywhere else, so a part can carry its own breakpoints. Each part merges on its own, so an override on root cannot disturb title. Extra classes come in as a second argument, keyed by part:

card({ size: "lg" }, { root: className, title: ss({ lg: "text-2xl" }) })

Each part's extra is a ClassArg for the same reason as above: a responsive override from the call site goes through ss().

extend — building on another recipe

const brand = variants({
  extend: button,
  base: "font-medium",
  variants: { tone: { brand: "bg-violet-600" } },
  defaults: { tone: "brand" },
});

brand({ tone: "danger" });   // still there — merging is per *option*, not per group

The parent's base, variants, compounds and defaults come first; anything declared here wins. Merging per option is what lets a product package add one tone to a design system's button without forking it. Types merge too, so VariantProps<typeof brand> includes the inherited options. Slotted recipes extend the same way, gaining parts.

Coming from cva or tailwind-variants

Same shape, and the renamed keys are accepted as aliases — so a port is cva( -> variants(, plus the two call-site differences marked below. Every line is verified against the current build.

| cva / tv | tailess | | | --- | --- | --- | | variants | variants | unchanged | | defaultVariants | defaults | | | compoundVariants | compound | | | class / className in a compound rule | either | className is an alias; class wins if both are given | | cva("base", { … }) | variants("base", { … }) | the same call shape; base also works as a config key | | button({ tone: "danger", class: "mt-2" }) | button({ tone: "danger" }, "mt-2") | a difference: extra classes are a second argument, like cn; class/className in the props is not applied, and warns in development | | VariantProps<typeof button> | VariantProps<typeof button> | unchanged | | slots | slots | returns a record of strings, not slot functions | | extend | extend | merges per option, so an inherited one is not dropped | | { intent: ["a", "b"] } in a compound | same | | | disabled?: boolean | same | | | tv: an omitted boolean prop picks its false option | an omitted prop picks nothing, as in cva | a difference: add defaults: { disabled: false } to keep tv's behaviour — a false option or a false compound rule does not apply otherwise |

- import { cva } from "class-variance-authority";
+ import { variants } from "tailess";

- const button = cva("rounded", {
+ const button = variants("rounded", {
    variants: { tone: { … }, size: { … } },
    compoundVariants: [{ tone: "danger", className: "ring-2" }],
    defaultVariants: { tone: "primary" },
  });

Beyond the two marked differences, that is the whole port — the renamed keys are accepted as written and the call shape is the same, which is why there is no codemod to run.

What you gain. A variant option can be an ss map, so it carries its own breakpoints and states — lg: { base: "text-lg px-4", md: "px-6" }, which a flat string cannot say. Everything ends in one ss call, so tailwind-merge runs once across base, variants, compounds and the caller's className together.

What is deliberately absent. Responsive variant selection at the call site — size={{ base: "sm", md: "lg" }} — is not supported and will not be: the scanner reads your recipe, never the call sites of the component it builds, so it would have to enumerate every option under every breakpoint and container size, or let the class land with no CSS. Put the breakpoints inside the option instead (lg: { base: "text-lg", md: "px-6" }), which is statically knowable and is the shape this is built around.

Boolean variants take a boolean, the same as in cva and tv — the option keys are the strings "true"/"false", and both spellings are accepted:

const box = variants({ variants: { disabled: { true: "opacity-50", false: "opacity-100" } } });
box({ disabled: isDisabled });   // forward the prop you already have

withPrefix — the escape hatch

For variants tailess doesn't model as keys: an arbitrary variant, an arbitrary group-*/peer-* modifier, or one a plugin or your own @custom-variant defines.

withPrefix("[&>li]", "border-b");               // → "[&>li]:border-b"
withPrefix("group-[.open]", "rotate-90");       // → "group-[.open]:rotate-90"
withPrefix("peer-[.is-invalid]", "text-red-600");
withPrefix("sidebar-open", "translate-x-0");    // a @custom-variant of your own

vars — values a class cannot carry

Every class tailess produces has to be enumerable at build time, so the values inside it are written literally in your source. A width that comes from data is not, and w-[`${percent}%`] has no CSS behind it however it is built. Keep the class literal and put the value in a custom property:

<div
  className={ss({ base: "w-[var(--w)]", md: "w-[var(--w-md)]" })}
  style={vars({ "--w": `${percent}%`, "--w-md": "50%" })}
/>

Numbers are stringified, and null, undefined or "" drops the property rather than writing an invalid declaration — so a conditional variable reads like a conditional class.

vars({ "--w": "42%", "--gap": 8 });        // → { "--w": "42%", "--gap": "8" }
vars({ "--w": "42%", "--h": undefined });  // → { "--w": "42%" }

configure — the two things that depend on your project

// entry.ts, before anything renders
import { configure } from "tailess";
import { extendTailwindMerge } from "tailwind-merge";

configure({
  merge: extendTailwindMerge({ extend: { classGroups: { "font-size": ["text-hero"] } } }),
  onWarn: process.env.CI ? (m) => { throw new Error(m); } : undefined,
  keys: ["3xl", "sidebar-open"],
});

merge is how conflicting classes are resolved, twMerge by default. tailwind-merge only knows Tailwind's own utilities, so a project with its own @utility or theme scale needs extendTailwindMerge — without it cn("text-sm", "text-hero") emits both and the winner is decided by CSS source order rather than by argument order, which is the one guarantee cn makes. Pass (classes) => classes to skip merging entirely.

onWarn is where a development warning goes, console.warn by default. Throw to make them fatal in CI, collect to assert on them in a test, or pass () => {} to silence them. Each warning is reported once per process, so passing onWarn also clears that history — otherwise a collector set up after the code under test had already warned would stay empty and the assertion would pass without asserting anything. resetWarnings() clears it on its own, for a test that asserts on the same warning twice.

keys is the runtime half of the next section.

The settings are process-global — one set, shared by the ES module and CommonJS builds when a process loads both — and the last call wins for every render already in flight. That is why it belongs at module scope of your entry. Calling it per request — or per tenant in a shared SSR process — is not supported; two requests configuring different merge functions produce wrong output for one of them, with no error.

Keys your own CSS adds

The built-in keys are a closed union on purpose — that is what makes a typo a compile error rather than an unstyled element. But a @theme that adds --breakpoint-3xl, or a @custom-variant sidebar-open, creates a variant that genuinely works and that tailess cannot know about. Declare it and it joins the union:

// tailess.d.ts, anywhere your tsconfig includes
export {}; // makes this file a module, so the block below adds to tailess's types
declare module "tailess" {
  interface CustomKeys {
    "3xl": true;
    "sidebar-open": true;
  }
}

The export {} matters. A .d.ts with no import or export of its own is a global script, and there declare module "tailess" does not add to the package's types — it replaces them, and every import { ss } from "tailess" stops compiling.

The package ships one set of declarations for import and one for require, and the block adds to whichever one its own file resolves. A project that mixes the two — .cts files in a "type": "module" package, or .mts in a CommonJS one — needs the same block in a file of each kind: tailess.d.ts and a copy named tailess-cjs.d.cts.

ss({ md: "p-6", "3xl": "p-12", "sidebar-open": "translate-x-0" });

Name them in configure({ keys: [...] }) too, or the runtime — which cannot see a type — goes on calling them unknown on every render. Declared keys are emitted after the built-in ones, in the order given: Tailwind's ordering has no place for them, and a stable position is what tailwind-merge needs.

Also exported

import { screens, screenKeys, maxScreenKeys, containerKeys, maxContainerKeys, stateKeys } from "tailess";

screens.md;                                               // "48rem"
window.matchMedia(`(min-width: ${screens.md})`).matches;  // true above 768px

Every exported type, grouped by what it is for. A test holds this list to what src/index.ts actually exports, so it cannot fall behind.

| | | | --- | --- | | ss itself | SsInput SsValue SsArg ClassArg SsKey ClassValue ResponsiveMap | | Key families — for a Record<…> keyed by one | ScreenKey MaxScreenKey ContainerKey MaxContainerKey AnyContainerKey StateKey ElementStateKey StandaloneStateKey GroupStateKey PeerStateKey HasStateKey InStateKey NotStateKey NegatableStateKey | | Recipes | VariantProps VariantsConfig VariantComponent VariantGroups VariantOptions CompoundRule SlotDefaults SlotValue SlottedConfig SlottedComponent SlottedGroups | | The rest | NthValue CssVars CssVarInput CssVarName CustomKeys TailessSettings ConfigureOptions |

The plugin option types are on their own entries: TailessViteOptions from tailess/vite, TailessPostcssOptions from tailess/postcss, and CollectOptions, CollectResult, FileDiagnostic, Diagnostic, DiagnosticMode, BreakpointDecl and CollectedTheme from tailess/build.

Keys

ss accepts base plus Tailwind's own keys — 305 in total, and nothing else, so autocomplete is exhaustive and a typo can't compile. The same 305 are available inside a nested group, which is how a compound variant is spelled.

| Group | # | Keys | | :-- | --: | :-- | | base | 1 | unprefixed classes | | Breakpoints | 5 | sm md lg xl 2xl | | Max-width ranges | 5 | max-sm max-md max-lg max-xl max-2xl | | Container queries | 13 | @3xs @2xs @xs @sm @md @lg @xl @2xl @3xl @4xl @5xl @6xl @7xl — sized by the nearest @container, not the viewport | | Container ranges | 13 | @max-3xs … @max-7xl | | Interaction & links | 7 | hover focus focus-within focus-visible active visited target | | Position among siblings | 9 | first last only odd even first-of-type last-of-type only-of-type empty | | Form & input state | 16 | disabled enabled checked indeterminate default optional required valid invalid user-valid user-invalid in-range out-of-range placeholder-shown autofill read-only | | Element state | 2 | open inert | | Pseudo-elements | 10 | before after first-letter first-line marker selection file backdrop placeholder details-content | | Media & feature queries | 17 | dark motion-safe motion-reduce contrast-more contrast-less forced-colors inverted-colors portrait landscape print noscript pointer-fine pointer-coarse pointer-none any-pointer-fine any-pointer-coarse any-pointer-none | | Direction & transition | 3 | rtl ltr starting | | Descendants | 2 | * direct children · ** all descendants | | group-* | 36 | the element's own state — the four state rows plus rtl/ltr — matched on the parent: group-hover, group-checked, … | | peer-* | 36 | the same 36, matched on a sibling: peer-hover, peer-checked, … | | has-* | 36 | the same 36, matched on a descendant: has-checked, has-focus, … | | in-* | 36 | the same 36, matched on an ancestor: in-focus, in-hover, … | | not-* | 58 | those same 36, plus every media query and breakpoint: not-hover, not-dark, not-md, … |

Anything with a value of its own (data-*, aria-*, supports-[…], has-[…], in-[…], arbitrary min-[…]) is deliberately absent — use data/aria, supports, has/notHas/inside, group/peer/container for the named forms, nth for positions, or withPrefix. The exact list is exported as stateKeys and is regenerated and re-verified against the Tailwind compiler in CI.

The breakpoint keys are Tailwind's five defaults. A @theme of your own can add to them, remove them or move them, and none of that reaches the type — so the plugin reads your CSS and says so at build time. A breakpoint you added is reachable as withPrefix("3xl", …).

Framework examples

import { ss, match } from "tailess";

export function Card({
  tone,
  wide,
  disabled,
  className,
}: {
  tone: "info" | "danger";
  wide: boolean;
  disabled: boolean;
  className?: string;
}) {
  return (
    <div
      className={ss(
        {
          base: "rounded-lg border p-4",
          md: wide && "p-6",
          dark: { base: "border-neutral-800", hover: "border-neutral-700" },
          hover: "shadow-md",
          "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,
      )}
    />
  );
}
<script setup lang="ts">
import { ss } from "tailess";
const props = defineProps<{ active: boolean }>();
</script>

<template>
  <div
    :class="ss(
      { base: 'rounded p-4', md: 'p-6', dark: { hover: 'bg-neutral-800' } },
      props.active && { base: 'ring-2' },
    )"
  >
    It's fine to write prose with apostrophes here.
  </div>
</template>
<script lang="ts">
  import { ss } from "tailess";
  let { active = false } = $props();
</script>

<div
  class={ss(
    { base: "rounded p-4", md: "p-6", dark: { hover: "bg-neutral-800" } },
    active && { base: "ring-2" },
  )}
>
  Let's go — apostrophes in markup are fine.
</div>

What the scanner can and cannot see

The scanner reads literal strings at your call sites. It over-approximates on purpose: both branches of a ternary, every key of an object, every element of an array. Extra candidates cost nothing — Tailwind ignores ones that don't resolve — while a missing one costs you the style.

✅ Seen

ss({ md: "text-2xl", hover: "underline" })          // literals
ss({ md: isWide ? "grid-cols-3" : "grid-cols-1" })  // both branches
ss({ md: ["flex", cond && "gap-4"] })               // arrays
ss({ md: [{ "text-lg": cond }] })                   // clsx dictionaries, quoted…
until("md", { hidden: !open })                      // …or not
ss({ md: "p-4", /* both survive */ lg: "p-6" })     // comments anywhere
ss({ dark: { hover: "bg-black" } })                 // nesting — dark:hover:bg-black
ss(a, cond && { sm: "bg-red-500" })                 // a map behind a condition
ss(a, open ? { md: "p-6" } : { md: "p-2" })         // both branches, as maps
ss({ md: withPrefix("has-[:x]", "underline") })     // a helper inside a group stacks
on(["dark", "hover"], "bg-black")                   // compound variants
data("state", open ? "open" : "closed", "p-2")      // both values
data("level", 2, "p-2")                             // numbers and booleans
supports("display: grid", "grid")                   // the space is escaped for you
group("row", "hover", "underline")                  // group-hover/row:underline
has("> img", "p-0")                                 // the space is escaped for you
nth(open ? 3 : 4, "bg-neutral-50")                  // a number, or an expression
variants({ variants: { s: { lg: { md: "p-6" } } } }) // only the leaves are classes

❌ Not seen — the value isn't in the source to read:

const size = "text-2xl";
ss({ md: size });                    // a variable
ss({ md: `text-${scale}` });         // an interpolated template
ss({ ...spread });                   // a spread
ss({ md: { [key]: "grid" } });       // a computed key
withPrefix(dynamicPrefix, "grid");   // a computed prefix

The scanner also finds helpers by name, so a renamed import hides them:

import { ss as tw } from "tailess";
tw({ md: "p-6" });                   // ✗ not found — nothing supplies md:p-6

import * as t from "tailess";
t.ss({ md: "p-6" });                 // ✓ a namespace import is fine
t.ss?.({ md: "p-6" });               // ✓ so is an optional call
t["ss"]({ md: "p-6" });              // ✗ not found — an element access is not a name

It reads a call's arguments as text, without a full JavaScript parser, so a regular expression holding a quote — ss({ md: "p-4" }, s.replace(/"/g, "") && { lg: "p-6" }) — can hide the classes after it. Compute that value before the call.

If you need one of those, put the literal somewhere the scanner can reach it — usually by writing the full class in a match() lookup, which needs no build integration at all because every class in it is already a literal:

const size = match(scale, { sm: "text-sm", lg: "text-2xl" });

When the value is genuinely continuous — a percentage, a pixel count — there is no set of literals to write. Keep the class literal and move the value into a custom property with vars.

Scanned by default: tsx ts mts cts jsx js mjs cjs mdx md html vue svelte astro. Markup files work the same as JS ones — an apostrophe in your prose or a :class="…" attribute won't throw the scanner off.

Build-time checks

The plugin reports what it can prove wrong from your source, while the project builds:

[tailess] src/Card.tsx: "p-4" never reaches the element — "p-2" replaces it in the same
  string. Drop the unused one, or move the override into its own argument.
[tailess] src/Card.tsx: between("lg", "sm", …) describes an empty range: "lg" is not
  narrower than "sm", so "lg:max-sm:" can never match a viewport.
[tailess] src/app.css: your theme removes the "sm" breakpoint, but tailess still offers
  it as a key — ss({ "sm": … }) compiles, emits "sm:", and no rule is generated for it.

Eleven things are checked: two conflicting utilities in one string, a between range no viewport can satisfy, an empty prefix, whitespace inside a variant, an arbitrary value no class name can carry — a supports query, a has/inside selector, an nth position — a helper imported under another name, an ss map handed to a helper that takes a flat class value, a prefixed bucket whose value the scanner cannot read, a prefixed class whose {, } or \ cannot be handed to Tailwind, CSS that moves the variants out from under the keys, and CSS that imports Tailwind with a prefix(…). Each is a class that cannot work — nothing is reported for code that merely looks unusual, and a later argument overriding an earlier one is never flagged, since that is the point of passing className last. The source checks speak only about calls that are really tailess's: a bare call under a name the file imports from "tailess" — by import, require or await import() — or a member of a name the whole package is bound to (import * as tl, const tl = require("tailess")). Solid's on, emitter.on(…) and $(el).on(…) are left alone. So is a file that reaches the helpers through a local re-export — a @/lib/utils barrel — which keeps full class enumeration but not these checks; import from "tailess" in the files you want checked.

Two conflicting utilities are judged with the default tailwind-merge, because the build cannot run yours. A project that calls configure({ merge }) gets no reports of that kind rather than wrong ones.

A renamed import is the widest of them. The scanner finds calls by identifier, so import { ss as tw } from "tailess" is one line that removes every prefixed class its tw(…) calls build from the candidate list — while the file compiles, type-checks and renders exactly the class attribute you wrote. Renaming cn or match is free; renaming a helper that builds a variant prefix is not, and that is what this reports.

A bucket the scanner cannot read is the package's most common support case, and the type system cannot express any of it — ss({ md: size }) is perfectly well typed and completely unstyled. Every part of a value under a prefixed key that becomes a class — both branches of a ternary, both sides of || and ??, each array element — has to be one the scanner can see, and the one that is not is reported by name:

ss({ md: size })                 // ❌ reported
ss({ md: `text-${scale}` })      // ❌ reported
ss({ md: cond ? size : "p-2" })  // ❌ reported — "p-2" says nothing about `size`
ss({ md: [size, "flex"] })       // ❌ reported
ss({ base: size })               // ✅ no prefix, so Tailwind finds the literal itself
ss({ md: cond && "p-4" })        // ✅ the sweep reads both halves

A helper call inside a bucket — ss({ md: on("hover", size) }) — is read as its own call, and its arguments are not checked from here.

An ss map in the wrong place. Composition runs one way — a helper nests inside an ss bucket, never the reverse. Every helper's class argument is a ClassValue, where an object is a clsx dictionary (until("md", { hidden: !open }) is the documented shape), so an ss map handed to one is read as a dictionary and its keys become the classes: on("hover", { base: "underline", md: "font-bold" }) builds "hover:base hover:md". The types cannot refuse it — a clsx dictionary is any object, so an ss map is one too — and the runtime is completely silent, so this check is what catches it. It reads the same mistake in a responsive breakpoint (responsive("p-2", { md: { hover: "p-4" } }) builds md:hover) and in a match option.

ss({ md: on("hover", "underline") })      // ✅ this way round — "md:hover:underline"
on("hover", { base: "underline" })        // ❌ "hover:base"

A Tailwind prefix(…) is the one failure that is total rather than local. @import "tailwindcss" prefix(tw) makes the working class tw:hover:underline, and tailess builds hover:underline — so nothing on the page has styles. tailess does not support a Tailwind prefix; the check exists so you find that out from your build rather than from a blank screen.

The theme check is the other one that reads your CSS rather than your source, and the only one anywhere in the list with cases that are informational rather than broken. The breakpoint keys are compiled into the package, so --breakpoint-sm: initial leaves ss({ sm: … }) compiling and emitting a class nothing generates a rule for, --breakpoint-md: 50rem leaves screens.md returning the old width to your JS, and the resets --breakpoint-*: initial and --*: initial do the first of those to every breakpoint at once. Adding one is reported too — that CSS works, so this is the exception to the rule above, and it is there because the compile error you get from ss({ "3xl": … }) says nothing about withPrefix("3xl", …), which does — or declare the key and use it like any other.

@custom-variant is read the same way. Defining one gives you a variant that works — midnight:bg-black — but no key, so ss({ midnight: … }) will not compile; the warning names withPrefix("midnight", …), which does — declaring it is the other answer. Redefining a name that is a key is not reported: Tailwind just replaces the variant and the key still resolves.

A @config pointing at a v3-style JS config can set theme.screens and add variants of its own. That is a JavaScript file this never opens, so one anywhere in your stylesheet chain silences this check entirely — no answer rather than a wrong one.

The runtime warns about most of these too, but only once the line renders, in a browser, with the console open. A branch that did not run during development ships either way — these run on every build, for every call site, and show up in CI. They warn; they never fail the build — tailess check is the one that does.

Checking your build

The plugin guarantees the bridge: it enumerates the classes tailess builds at runtime and hands them to Tailwind. It cannot guarantee the far end — that Tailwind generated a rule for each one. A @theme that dropped a breakpoint, a @config this deliberately stays quiet about, or an arbitrary value Tailwind rejects all leave the bridge intact and the element unstyled.

tailess check compiles your project for real and looks:

npx tailess check
[tailess] src/app.css: your theme removes the "md" breakpoint, but tailess still offers it as a key — ss({ "md": … }) compiles, emits "md:", and no rule is generated for it.
[tailess] 1 of 3 runtime-built classes reach the element with no rule behind them:

  md:p-4
    src/Card.tsx
    "p-4" resolves on its own, so the variant is what fails.

Usually a @theme that moved a breakpoint, a variant your CSS redefines, or an arbitrary value Tailwind rejects.

It exits 1 when something is wrong, so it can gate a build:

- run: npx tailess check --strict

Use --strict in CI. The check compiles your stylesheet with the candidates the plugin would inject, so on its own it proves the far end, not that the plugin is wired up: with tailess() deleted from the config it prints a "no build config here calls the plugin" warning and still exits 0. --strict turns that warning — and the build-time checks — into exit 1. It is what CI runs on examples/vite-react, which asserts the gate goes red with the plugin removed.

| | | | --- | --- | | --content <dir> | where your source lives. Repeatable, or comma-separated. Defaults to the working directory. | | --css <file> | your Tailwind entry stylesheet. Found automatically when it sits inside a --content root. | | --extensions <list> | file extensions to scan, replacing the default list. | | --ignore <list> | extra directory names to skip. | | --strict | also fail on the build-time checks, which are otherwise printed without affecting the exit code. | | --max <n> | how many broken classes to name before summarising. Default 20; 0 lists them all. | | --json | print one JSON object instead of prose. | | --version | |

[!IMPORTANT] Give --content, --extensions and --ignore the same values as the plugin, or the gate reads a different set of files than your build does — a project scanning ["tsx", "vue"] has a build enumerating two extensions and a gate reading fourteen, and a plugin narrowed to content: ["src/pages"] builds nothing for src/components while a gate reading all of src passes it. Wrong in both directions, and silently.

A class passes when any Tailwind entry stylesheet the check finds has a rule for it, since a component is styled by whichever one its page loads. A stale or unrelated entry under --content — a storybook, an email template, legacy/ — can therefore vouch for a class the app's own stylesheet cannot build: leave it out with --ignore, or name the app's entry with --css.

Every finding names the file it came from, and --json gives a CI job something to read:

{
  "tailess": 1, "command": "check", "ok": false, "code": 1,
  "checked": 312, "files": 84, "stylesheets": ["src/app.css"],
  "broken": [{ "class": "md:p-4", "utility": "p-4", "files": ["src/Card.tsx"] }],
  "diagnostics": [{ "kind": "dead-class", "file": "src/Row.tsx", "message": "…" }]
}

| Exit | Meaning | | ---: | --- | | 0 | every runtime-built class has a rule — or the scan ran and found no tailess calls | | 1 | a class reaches the element with no rule behind it | | 2 | nothing could be checked: no entry stylesheet (or none that generates utilities), no files scanned, a Tailwind prefix(), or a bad option |

2 is the one worth wiring an alert to. It means the gate did not run, which in CI looks nothing like a failure but proves exactly as much: a --content typo, a task runner in the wrong directory, or a glob where a directory was expected all land here rather than passing quietly. When the scan does find files but no tailess calls, the exit is 0 and the line says how many files it read, so the two are distinguishable in a log.

It also prints the build-time checks it computes on the way past. Those are the failures compiling cannot find — a class carrying an unusable value never reaches Tailwind to be found missing — so --strict is what makes one gate cover both.

The scanner over-approximates on purpose, so most of what it produces is not a class at all. Rather than demand a rule for every candidate — which would report all of that — the check asks whether the utility inside each class resolves on its own first. p-4 works and md:p-4 does not, so something between the two is broken; md:state has no working half, so it was never a class and is not reported.

It uses your Tailwind, resolved from your tree, and loads your @plugins and @config the way Tailwind itself does — so a variant or utility that only exists because of a plugin counts as generated rather than missing.

tailess/build — the scanner, as a library

Everything here that is not the runtime rests on one question — which classes can this source build at runtime? — and the two plugins and the binary were the only ways to ask. A webpack or rspack loader, an esbuild plugin, an Astro or Nuxt module, an editor extension, a lint rule, your own CI script: all of them need the same answer.

import { collect, buildPrelude, diagnose, themeDiagnostics } from "tailess/build";

const { classes, files, diagnostics } = await collect({ roots: ["src"] });
const css = buildPrelude(classes);   // the @source inline(...) Tailwind needs

It is Node-only — it walks the file system — which is why it is a subpath rather than part of tailess itself: the runtime pulls in no Node types at all, and that stays true. Also exported: extractClasses, isTailwindEntry, tailwindPrefixIn, collectTheme, reportDiagnostics, hasRule, selectorFor, defaultExtensions, defaultIgnore.

tailess emit — the stylesheet, as a file

The plugins hand Tailwind the class list in memory. tailess emit writes exactly the same thing to a file you @import yourself:

npx tailess emit --content src --out src/tailess.css
/* src/app.css */
@import "tailwindcss";
@import "./tailess.css";     /* the classes tailess builds at runtime */

It takes the same --content, --extensions and --ignore as the check, and writes to stdout when given no --out. Two things need it.

A host with no PostCSS chain. The plugins cover Vite and anything with a postcss.config. Tailwind's own CLI, Rspack's native CSS pipeline, Bun's bundler and the standalone binary compile Tailwind without ever running one — so run emit in the same script that builds your CSS:

{
  "scripts": {
    "css": "tailess emit --content src --out src/tailess.css && tailwindcss -i src/app.css -o dist/app.css"
  }
}

Publishing a component library. A consumer's scan skips node_modules, and even pointed at your package it would be reading a bundled dist where the helper names are gone. So enumerate the classes at your build time and ship the result — together with a line that points the consumer's Tailwind at your bundle, because emit writes only the classes tailess builds. Your base classes, flat variant options, match values and plain className strings are literals, which in an app Tailwind finds by scanning; in a consumer nothing scans your package unless your stylesheet says so.

{
  "scripts": { "build": "tsup && tailess emit --content src --out dist/tailess.css" },
  "files": ["dist", "styles.css"],
  "exports": { ".": "./dist/index.js", "./styles.css": "./styles.css" }
}
/* styles.css, at your package root, committed */
@source "./dist";            /* your bundle: every literal class in it */
@import "./dist/tailess.css"; /* the classes tailess builds at runtime */

@source resolves relative to the file it is in, so your consumer still adds one line, and needs neither the plugin nor a scan of your source:

@import "tailwindcss";
@import "@acme/ui/styles.css";

Ship only the emitted file and the prefixed classes arrive while every literal one is unstyled in the consumer — nothing in either build says so. test/integration/library.test.ts builds this recipe end to end.

The file is deterministic — same source, same bytes — so it diffs cleanly and caches.

Plugin options

Both plugins take the same four options:

tailess({
  content: ["src", "../ui/src"],  // files or dirs to scan
  ignore: ["fixtures"],           // extra dir names to skip
  extensions: ["tsx", "vue"],     // replaces the default list
  diagnostics: "error",           // "warn" (default) | "error" | "off"
});

diagnostics is what the build does about the checks the scanner can prove from your source. "warn" prints and keeps going, which is right in dev — a dead class should not stop you seeing the rest of the page. "error" prints the whole list and then fails, which is right in CI, where a warning about an unstyled element is an unstyled element that ships:

tailess({ diagnostics: process.env.CI ? "error" : "warn" })

The theme notes about CSS that works — a breakpoint or @custom-variant your CSS adds, a width it moves — are printed in every mode and fail nothing, so a project that declared its own keys builds under "error" too. A removed breakpoint still fails it: that one leaves classes with no rule.

The PostCSS plugin takes one more, cacheDir, since it has no host to borrow one from —