0.7.0
0.6.2
0.6.1
0.6.0
Minor Changes
#700
ea48ae2Thanks @thevuong! - Authoring a configuration is now type-checked as strictly as calling one. Three typos used to compile and then do nothing: adefaultVariantskey naming no variant, acompoundVariantskey naming no variant, and a variant class map naming no slot. Each failed the same silent way — nothing read the stray key, the compound never matched, the slot map was dropped — so a mistyped character removed a style with no error anywhere.The root cause was one overload.
extendwas optional onExtendedVariantConfig, which made the last overload a catch-all: a configuration the earlier three correctly rejected still matched it, andTBasewith nothing to infer from widened toVariantSchema, whose key isstring— so every mistyped name became legal again.extendis now required there, which is what that overload was always for. Alongside it,defaultVariantsandcompoundVariantsno longer act as inference sites for the variant schema (NoInfer), so a stray key is rejected instead of quietly widening the schema to include it.Two smaller corrections fall out.
defaultVariantsis typed by the newVariantValues<T>rather than the call-siteVariantSelection<T>, so it no longer accepts aclassNamea configuration has no use for. And in a slot configuration a variant's object value is now held to the declared slots —SlotClassValue<S>— because resolution has always read an object there as slot names rather than clsx conditions;basestays admissible whether or not it is declared, matching the plan that synthesises it.A configuration without slots keeps its clsx object values, and a compound condition naming an undeclared variant still resolves at runtime for JavaScript callers and merged configurations — it is only the typed authoring path that now rejects it, since no typed call could ever satisfy such a condition.
tests/types/common/config-authoring.test.tsholds all of this with@ts-expect-error, the first negative type tests in the package. The 110 existing assertions only ever proved what compiles, which is exactly how three gaps survived.One configuration shape stops compiling. Requiring
extendcloses the overload that used to accept anything, and that overload was also what accepted a configuration whose literal types had widened — a hoistedconst defaultVariants = { size: "sm" }, a hoistedcompoundVariantsarray, a spread of a shared partial. Those have type{ size: string }, which was never assignable to{ size?: "sm" }; they compiled only because the catch-all widened the schema to swallow them. Addas constto the hoisted value, or inline it.The error TypeScript reports for this is
Property 'extend' is missing, which names the last overload tried rather than the real mismatch. It is the same message a plain variant typo now produces. Nothing in this repository hit either case, but a consumer with a shared configuration fragment will.#700
93b18acThanks @thevuong! - A variant function now remembers what each selection resolved to, so a repeated selection skips both the plan walk andtailwind-merge. Against the previous build the resolution rows measure 1.08× to 11.9×, and againsttailwind-variantsthe suite geomean moves from 6.18× to 19.8×. The motivating measurement: in the merged lane most of the cost was never the merge algorithm —tailwind-mergecaches — but building and hashing the joined class string to look that cache up, which a key built from the selection avoids entirely.The key is a mixed-radix number, one digit per variant. A variant no compound tests is keyed by its group key, since two values sharing a key select the same classes; a variant a compound tests is keyed by the raw value, because a compound compares against what the caller passed and
trueand"true"share a group key while comparing differently. A call the key cannot represent — an axis past its capacity, a configuration too large to address in one safe integer, a clsx-shapedclassName— resolves the long way as before.Two consequences are worth knowing. A slot component called twice with the same selection gets back the same object of slot functions: stable enough for a React dependency array, and shared, so nothing may mutate it. And the store is bounded and keyed by the selection, so a component whose variant values are effectively unique per call fills it with entries nothing reads again — the new
cacheResolutions: falseoption turns it off for that component.Alongside it:
extendTailwindMergeis now memoised bytwMergeConfigidentity, so a design system handing one config to a hundred components builds one merge function instead of a hundred, each with its own cache; a slot resolution keeps only the props a compound can read rather than the caller's whole props object, which would otherwise pinchildrenfor as long as the entry lives; and each slot's merged text is memoised, so re-reading a slot no longer re-runs the merge.tvitself costs about a quarter of a microsecond more per component definition. The encoder is compiled on first resolution rather than intv, so a component that is defined and never rendered pays nothing for it.#701
710d533Thanks @thevuong! -@codefast/tailwind-variantsnow ships with no runtime dependencies of its own, matching whattailwind-variantshas always done. Two changes get it there.clsxis gone, replaced by a flattener inside the package. It is deliberately identical, corners included: abigintcontributes nothing despite being aClassValue, and an object's keys are read withfor…in, so an inherited enumerable one counts. Verified by running the behaviour sweep — every configuration, every variant value, every slot, with and without merging — againstmainand diffing: zero difference across 118,505 outcomes. The dependency was only ever reached at compile time anyway, since resolution stopped calling it when the plan was introduced.ClassValueis now declared here rather than re-exported from clsx, so the type survives the dependency leaving. Its shape is unchanged except thatClassDictionaryisRecord<string, unknown>rather than clsx'sRecord<string, any>— which means passing a function where a class value is expected is now a type error. It contributed nothing at runtime either way.tailwind-mergemoves from a dependency to a peer (>=3.0.0). A consumer who pins their own version now gets one copy at the version they chose, instead of a second one arriving underneath this package. Install it alongside —@codefast/uialready does on your behalf.#700
d0dd326Thanks @thevuong! - Compile the configuration once, intv, instead of re-deriving it on every resolver call.tvnow builds a plan. Variant groups become an array of entries whose default classes are already looked up, compound variants become flat condition lists whose fallbacks are already resolved, and every class value is flattened to a string. Resolution reads monomorphic fields and concatenates strings, so the per-call work that used to happen —Object.keysper compound variant, a dictionary lookup per variant per slot, an intermediate array plus a spread intoclsx— is gone.Slot resolution is also inverted. A slot map names only a few slots, so having each of N slots scan every variant meant mostly-missing lookups. Each variant value now carries the slot positions it targets, and one pass distributes classes into a per-slot buffer that every resolver of that call shares; a slot called without its own props just reads its entry. Compound slots ride the same pass.
Measured against the previous release on
benchmarks/tailwind-variants(paired A/B, one subprocess per side per scenario, three passes with the order alternated, median of per-pass ratios): every one of the sixteen scenarios is faster, geometric mean 2.86×.shape without tailwind-mergewith extreme-slots9.64× 5.99× slots5.55× 3.48× compound-slots4.94× 3.21× complex2.59× 2.00× simple/extends/create-tv/extreme2.22×–2.33× 1.56×–1.89× The merge-enabled rows gain least because merging, not resolution, is what is left in them. The suite's A/A noise floor on the same machine is ±1%.
The trade is a slower
tvcall: flattening every class value and precomputing slot positions costs roughly 400ns more for a simple config and ~3.4µs for a ten-slot one. That is once per component definition, at module load, and against a slot resolution that got ~1.6µs cheaper it pays for itself after about two renders.One behavioural change, in a corner: a configuration whose classes are all truthy but render to nothing —
tv({ base: {} })— now returnsundefinedwithtwMergeenabled, where it previously returned"". It already returnedundefinedwithtwMergedisabled, soundefinedis now what "no classes" means either way.
Patch Changes
#700
fe7e9e4Thanks @thevuong! - A variant value naming anObject.prototypemember no longer reaches it. A compiled variant group is indexed by whatever a caller passes, and it was a plain object — sogroup["toString"]answered with a function instead ofundefined. The flat lane concatenated that function's source text into the class string, and the slot lane read slot positions offObject.prototypeand threw:tv({ base: "block", variants: { size: { sm: "p-2" } } })({ size: "toString" }) → "block function toString() { [native code] }" tv({ slots: { base: "rounded" }, … })({ size: "__proto__" }).base() → TypeError: Cannot read properties of undefined (reading 'length')Groups and the slot index map are now compiled onto prototype-less objects, which closes both resolvers and the selection encoder at once since all three read the same object. It costs roughly twice what reusing the source object did, once per component definition.
Two smaller things fall out of the same reading. A value the group does not answer is no longer memoised into the id table, which a long-lived server rendering user-supplied values would otherwise grow without bound; and inherited keys no longer consume the ids a group's declared values need, which used to disable a resolver's cache permanently after a few junk values.
0.5.0
Patch Changes
#676
641e233Thanks @thevuong! - Collapse thetypesanddefaultlanes ofpackage.json#importsfrom fallback arrays to single strings.Node resolves an imports array by taking the first candidate it can parse, without checking that the file exists and without falling through — a specifier whose first candidate is missing throws
ERR_MODULE_NOT_FOUNDrather than trying the second../dist/*/index.jsand./dist/*/index.d.tscould therefore never be reached, so they read as a safety net that does not exist. Thesourcelane keeps its extension candidates, which onlytscand Vite read and both probe.
0.5.0-canary.9
Patch Changes
#676
641e233Thanks @thevuong! - Collapse thetypesanddefaultlanes ofpackage.json#importsfrom fallback arrays to single strings.Node resolves an imports array by taking the first candidate it can parse, without checking that the file exists and without falling through — a specifier whose first candidate is missing throws
ERR_MODULE_NOT_FOUNDrather than trying the second../dist/*/index.jsand./dist/*/index.d.tscould therefore never be reached, so they read as a safety net that does not exist. Thesourcelane keeps its extension candidates, which onlytscand Vite read and both probe.
0.5.0-canary.8
0.5.0-canary.7
0.5.0-canary.6
1.0.0-canary.7
1.0.0-canary.6
0.5.0-canary.5
0.5.0-canary.4
0.5.0-canary.3
0.5.0-canary.2
0.5.0-canary.1
0.5.0-canary.0
0.4.0
Patch Changes
2397801Thanks @thevuong! - chore: align package config globs6350584Thanks @thevuong! - refactor(tailwind-variants): clarify variant API namesf79b333Thanks @thevuong! - feat(dev): enable source condition for zero-rebuild HMR in apps/docs8432414Thanks @thevuong! - refactor(tailwind-variants): streamline variant resolution6c3ac44Thanks @thevuong! - Normalize import statement order and package.json key order repo-wide via the new oxfmtsortImports/sortPackageJsonsettings — purely mechanical, no runtime behavior change.d07b567Thanks @thevuong! - Simplify theextendresolver type by dropping a redundant generic parameter — public behavior and inference are unchanged.649cf5aThanks @thevuong! - fix(types): default the variant schema to a keyless record whentvis called withoutvariants, soclassName/classaccept fullClassValueinputs (arrays, objects) instead of being narrowed by a string index signature
0.4.0-canary.6
0.4.0-canary.5
Showing the latest 20 releases; the full changelog on GitHub has 40 more releases.