Migration
Upgrade guides for moving between Tailwind Variants major versions, including v3 to v4, v2 to v3, and the lite build split.
To v4.0.0
twMergeConfig moved to tailwind-variants/config
The default entry ships the compiled default tables only. twMergeConfig on tv, createTV, or cnMerge needs the table compiler, which lives on the config entry. That entry exports the same API plus extendTailwindMerge, createTailwindMerge, createTwMerge, fromTheme, validators, mergeConfigs, and getDefaultConfig.
- import { createTV } from 'tailwind-variants';
+ import { createTV } from 'tailwind-variants/config';
export const tv = createTV({ twMergeConfig });The default entry throws when a recipe is defined with twMergeConfig, so a missed import fails at definition time. The config entry exports TVCustomConfig and TWMCustomConfig; type a wrapper around its tv as TV<TVCustomConfig>.
To keep the default or lite import, inject a compiled merger instead:
import { createTwMerge } from 'tailwind-variants/config';
import { createTV } from 'tailwind-variants/lite';
export const tv = createTV({ twMerge: createTwMerge(twMergeConfig) });experimentalParseClassName removed
Compiled tables cannot run a per-class parse hook. Keep tailwind-merge and inject it:
import { extendTailwindMerge } from 'tailwind-merge';
import { createTV } from 'tailwind-variants';
export const tv = createTV({ twMerge: extendTailwindMerge({ experimentalParseClassName }) });cn drops a numeric 0
cn follows clsx on every entry. A lone 0 still stringifies. cx keeps 0:
cn('foo', 0); // => "foo" (3.3.1: "foo 0")
cn(0); // => "0"
cx('foo', 0); // => "foo 0"Empty string instead of undefined
tv(), cn(), cnMerge(), and cx() return "" instead of undefined when no classes remain. The CnReturn type is now string. Replace undefined comparisons with falsy checks:
- if (cn(classes) === undefined) { /* ... */ }
+ if (!cn(classes)) { /* ... */ }Stricter variant types
Unknown or misspelled keys in variants, compoundVariants, and defaultVariants now fail at compile time, as do unknown slot names in slot-shaped class values. They used to be silently ignored at runtime, so new errors after upgrading usually point at real typos:
const button = tv({
variants: { size: { sm: 'text-sm' } },
// ✗ now a compile error — 'colour' is not a variant key
defaultVariants: { colour: 'primary' }
});Peers dropped
tailwind-merge and tailwindcss are no longer peer dependencies. Conflict resolution ships in the default entry. Keep tailwind-merge only if your app imports it directly.
Lite invokes a function twMerge
Factory and per-call config on /lite accept twMerge only. A function is invoked. In 3.3.1 a function was treated as true:
import { createCN, createTV } from 'tailwind-variants/lite';
import { twMerge } from 'tailwind-merge';
export const tv = createTV({ twMerge });
export const cn = createCN({ twMerge });createTV does not bind cn.
twMerge: false joins with cx
Base, slot, variant, and compound class strings are whitespace-normalized once, when a recipe is first used. 'px-2 px-4' becomes 'px-2 px-4'. On the default entry this turns merging off at runtime. It does not drop the table; use /lite for that.
Array classes and the base slot
In slotted recipes, array-based classes in compoundVariants (without a slot key) and array variant values now apply to the base slot, matching string behavior. Audit slotted recipes if you relied on those classes being dropped.
Optional: adopt multi-extend
Chained extends still work, but a single definition can now compose several parents:
- const extended = tv({ extend: baseButton });
- const button = tv({ extend: extended, base: 'gap-2' });
+ const button = tv({ extend: [baseButton, focusRing], base: 'gap-2' });Pick one engine
Do not ship a second merger next to the default tables. Alias everything to this package, or keep the merger you have and inject it through the lite entry:
// app that already uses tv
resolve: { alias: { 'tailwind-merge': 'tailwind-variants', clsx: 'tailwind-variants' } }
// merge-only code, without the tv runtime
resolve: { alias: { 'tailwind-merge': 'tailwind-variants/merge', clsx: 'tailwind-variants/merge' } }Code that calls extendTailwindMerge aliases tailwind-merge to tailwind-variants/config.
To v3.3.0
Drop tailwind-merge for TV
Conflict resolution ships in the default build. Install only Tailwind Variants unless your app calls tailwind-merge directly:
- npm i tailwind-variants tailwind-merge
+ npm i tailwind-variantsKeep tailwind-merge if you still use twMerge, extendTailwindMerge, or similar elsewhere.
Slots empty object
Passing slots: {} enables slot mode with an implicit base slot. Omit slots when you want a plain string return.
// Slot mode — returns { base: () => string }
const x = tv({ slots: {}, base: 'p-4' });
// String mode — omit slots
const y = tv({ base: 'p-4' });cn → cnMerge for config
The v3.2.2 split still applies:
// Before
cn('px-2', 'px-4')({ twMerge: false });
// After
cnMerge('px-2', 'px-4')({ twMerge: false });
cn('px-2', 'px-4'); // string, default mergeExtended types
Extended component metadata now reflects merged variants, slots, and keys at runtime. Slotted components include the implicit base slot in types.
v3.2.0 → v3.2.2
cn returns a string
cn no longer accepts a config callback. Use cnMerge for custom merge behavior.
import { cn, cnMerge } from 'tailwind-variants';
cn('px-2', 'px-4'); // => "px-4"
cnMerge('px-2', 'px-4')({ twMerge: false }); // => "px-2 px-4"v3.1.1 → v3.2.0
Replace cnBase with cx
- import { cnBase } from 'tailwind-variants';
+ import { cx } from 'tailwind-variants';
- cnBase('flex gap-2', className);
+ cx('flex gap-2', className);cn defaults to merge
On the default build, cn resolves conflicting Tailwind classes automatically.
v2 → v3
Two builds
// Default. Conflict resolution included (v3.3+)
import { tv, cn, cx } from 'tailwind-variants';
// Lite. No built-in table
import { tv, cx } from 'tailwind-variants/lite';If you already have twMerge, inject it. See Configuration.
Removed APIs
responsiveVariants— use Tailwind responsive prefixes in class strings (Responsive)withTv— usetvdirectly- Lazy merge loading — replaced by explicit default vs lite builds
Performance
v3 is significantly faster for merge operations. The default build includes merge. Lite has no table unless you inject a function.
See Class Resolution and Releases for v3.3.0 details.