Performance & size
Measured runtime cost per call, cold and warm scan times, an honest breakdown of the 11.1 kB bundle-size badge, and how each number is verified.
Runtime cost
Every number here is measured on the built package under Node 22. The first three rows are per call, warm; the last two are the build plugin walking a whole project.
| Operation | Cost |
|---|---|
| cn("px-2 py-1", …, "px-4") | ~123 ns |
| ss() with 3 groups | ~385 ns |
| ss() with 8 groups | ~970 ns |
| Cold scan, 2,000-file project | ~98 ms |
| Warm rescan, same project | ~17 ms |
A single-map ss call takes a dedicated path with no argument loop, so it costs what it did before variadic arguments existed. Each further argument is one more map walk, a nested group costs the same as a top-level one, and there is exactly one tailwind-merge pass per call whatever the shape.
Passing only class strings costs about what cn does — given only class values, ss is cn.
The two scan rows are build time, not browser time. The plugin caches extraction per file by mtime and size, coalesces concurrent scans, and only rewrites the sidecar when the class list actually changed, so an unrelated keystroke costs a stat rather than a re-parse.
Bundle size
The bundle-size badge reads 11.1 kB because it measures the whole dependency tree. That number is real, but almost none of it is tailess.
| Package | min+gzip |
|---|---|
| tailwind-merge | ~8.4 kB |
| tailess itself | ~2.7 kB |
| Total | ~11.1 kB |
tailwind-merge is the one runtime dependency, and it is what a cn() helper is built on in essentially every Tailwind codebase — roughly two thirds of Tailwind installs already pull it in.
You may have paid for most of this already
tailwind-merge, your bundler keeps the single shared copy and adding tailess costs the 2.7 kB, not the 11.1 kB.About a fifth of tailess' own 2.7 kB is the text of its development-time warnings. That text cannot be dead-code-eliminated without either risking a crash when the package is loaded unbundled or putting a process.env read on the render path — both were measured and rejected.
Why clsx is not a dependency
clsx is the other half of the usual cn() pairing, and tailess does not depend on it. An internal join module does the same job in about forty lines, which keeps the dependency count at 1.
The swap was worth making for two reasons. That code compresses slightly better alongside the rest of the package than clsx does on its own — 35 gzipped bytes smaller in the end — and it removes a package from the tree.
clsx stays a devDependency purely as a test oracle, which is what makes the replacement checkable rather than merely plausible.
Verified on
The test suite runs the real Tailwind compiler over real fixture directories and asserts the generated rules exist — the only assertion that actually fails when the bridge breaks. It covers both plugins, the split-CSS-entry @import chain, the add-a-class and delete-a-class dev cycle, the inline fallback path, and every one of the 149 keys.
- Tests
- 340, across 24 files
- Coverage
- 96% statements, 91% branches
- Tailwind
- 4.3.3
- Manually verified
- Vite 8 with
@tailwindcss/vite4, Next.js 16 (Turbopack and webpack)
Two suites check a claim against a second implementation rather than against a list someone typed. The parity suite hands the scanner a source string, evaluates that same string with the real helpers, and asserts every prefixed class the runtime produced is among the candidates the scanner found — so the two halves of the bridge cannot drift apart in the one direction that matters.
The join suite does the same for the clsx replacement, with clsx itself as the oracle: null-prototype objects, Proxies, boxed primitives, a getter that throws, symbol keys, 200-deep nesting, and the circular array that overflows the stack in both, plus 50,000 generated cases from a seeded PRNG so a failure replays exactly.
CI additionally runs lint, typecheck, publint and arethetypeswrong on every push, and the release workflow cannot publish unless all of them pass.