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.
Maintainers
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
- Requirements
- Install
- Setup
- Sorting classes
- Editor setup
- API
ss— group by breakpoint and statecn— compose and mergeresponsive— mobile-firstuntil/between— max-width rangeson— state variantsdata/aria— attribute variantssupports/notSupports— feature queriesgroup/peer/container— named variantshas/notHas/inside— selector variantsnth— position variantsmatch— exhaustive variant selectionvariants— component recipeswithPrefix— the escape hatchvars— values a class cannot carryconfigure— the two things that depend on your project- Keys your own CSS adds
- Also exported
- Keys
- Framework examples
- What the scanner can and cannot see
- Build-time checks
- Checking your build
tailess/build— the scanner, as a librarytailess emit— the stylesheet, as a file- Plugin options
- Performance
- Troubleshooting
- FAQ
- Upgrading from 0.8
- Contributing
- License
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 tailessA 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?ssis a superset of it — the same call with strings and arrays behaves identically, so you can swap one file at a time. A bareclsxdictionary is the exception:ssreads an object as a bucket map, socn({ "font-bold": on })becomesss([{ "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 configinit 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— nottailess/postcss.@tailwindcss/vitecompiles CSS in apretransform, which runs before Vite's PostCSS stage, so a PostCSS plugin can never reach it. (If your Vite project gets Tailwind throughpostcss.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,insideor thenth*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 tohas("[data-open] table", …)— Tailwind knowstableas 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", andtailwind-mergethen keepsp-4where it keptp-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
clsxclasses. Nothing is decided by looking at your key names, so the same source always means the same thing. Put aclsxdictionary 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 fallbackEvery 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 cnBoth 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 groupThe 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 havewithPrefix — 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 ownvars — 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 768pxEvery 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 prefixThe 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 nameIt 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 halvesA 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 --strictUse --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,--extensionsand--ignorethe 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 tocontent: ["src/pages"]builds nothing forsrc/componentswhile a gate reading all ofsrcpasses 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 needsIt 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 —
