Why the package exists and the choices that fix its API. ARCHITECTURE.md is the source of truth
for the internal shape — the compiled plan, the selection cache, which shapes are load-bearing;
README.md documents the API. Each decision below still holds; a decision that stops holding gets
replaced here, not annotated.
1. A drop-in replacement, not a new API
Context. @codefast/ui styles every component with tailwind-variants. Its configuration shape — base,
variants, defaultVariants, compoundVariants, slots, compoundSlots, extend — is the right vocabulary; what
fell short was resolution cost on hot render paths and some type-inference edges. Inventing a different API would have
forced a rewrite of the consumer for no gain in expressiveness.
Decision. Keep the upstream configuration shape and option names (twMerge, twMergeConfig) exactly, so the
migration for most codebases is the import specifier. The exports are tv, createTV, cn, cx, VariantProps and
the configuration types.
Consequences. The README's examples run as tests and a behaviour sweep covers the upstream shapes, and
@codefast/ui moved over by changing imports. The constraint runs the other way too: an API idea that upstream users
could not express is out of scope here.
2. Two verified departures from upstream
Context. Upstream's createTV(options) returns a bare tv, exposes a mutable defaultConfig, and ships cnMerge.
Global mutable configuration is the one upstream shape that fights the compile-once design below: a plan compiled under
one merge config cannot be trusted after the global changes.
Decision. createTV(options) returns { tv, cn } that share the options, and there is no mutable global: no
defaultConfig, no cnMerge. Local tv(config, options) still overrides the factory.
Consequences. Every resolver knows its merge configuration at compile time. Migration needs one destructure where
upstream assigned createTV's result directly; the README records both departures.
3. Settle everything once, at tv()
Context. A configuration is fixed the moment tv is called; the resolver it returns runs on every render, forever.
Upstream pays for dictionary walks and array flattening on each call.
Decision. tv() compiles the configuration into a plan — flattened class strings, precomputed slot positions,
compound conditions turned into checks — and resolution is string concatenation over that plan. This makes tv() itself
slower than upstream's by design.
Consequences. The per-component definition cost is paid within a render or two of resolution savings; the benchmark
suite measures both sides (construct-* rows against the resolution rows) so the trade stays visible. Anything that
would move work back into the resolver is a regression, whatever it saves at definition.
4. Cache resolutions per selection, with an opt-out
Context. A list renders the same few variant selections thousands of times, and both the plan walk and the merge are pure functions of the selection.
Decision. A resolver remembers what each selection resolved to, in a bounded store keyed by the selection. Slot
components return the same object of slot functions for the same selection. cacheResolutions: false disables the store
for a component whose values are unique per call.
Consequences. Repeated selections cost a lookup. Two things callers must know, and the README says both: the returned slot object is shared, so it must not be mutated; and a variant fed ids or timestamps should opt out or it fills the store with entries nothing reads again.
5. tailwind-merge is a peer, and merging is optional
Context. Conflict resolution is what makes className overrides safe, but tailwind-merge is a sizeable dependency
whose version a design system wants to pin once, not receive twice.
Decision. tailwind-merge >= 3 is a peer dependency, not a dependency; the package ships ESM only with no runtime
dependencies of its own. twMerge: false keeps every declared class for callers that resolve conflicts elsewhere, and
twMergeConfig extends the class groups for custom themes.
Consequences. One copy of tailwind-merge per application, at the version the application chose. Callers who turn
merging off own their conflicts.
6. Types describe what the selection can be, no more
Context. Variant props are the public contract a component's own props extend, so a wrong inference is a wrong component API.
Decision. VariantProps<typeof resolver> derives the selection type from the configuration, boolean variants accept
true/false and default to false, and className / class accept any ClassValue. Inference is checked by static
type tests under tests/types/ alongside the runtime suite.
Consequences. A change to the configuration types has to keep those type tests green; that is where a regression in inference shows up first.
7. Performance claims live in the benchmark suite
Context. "Faster than upstream" is the package's reason to exist, and a number written into a document rots the day the code changes.
Decision. No figure appears in this file, the README or the source. The first-party suite in
benchmarks/tailwind-variants runs the same workloads against tailwind-variants and class-variance-authority in
isolated subprocesses; its results ledger is the only place a ratio is recorded, and the README tells readers to run it.
Consequences. A hot-path change is judged by re-running the suite, not by argument. ARCHITECTURE.md states what a
shape guarantees; the suite says what it costs.
8. Not built, deliberately
A browser devtools extension, a VS Code extension, a CLI migration tool and CSS-in-JS integration were once listed as a roadmap. None is scheduled: the migration from upstream is an import change, the type surface already drives editor completion, and the package stays framework-agnostic by having no runtime beyond string work.
License
Released under the MIT License.