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-variants

Keep 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 merge

Extended 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 — use tv directly
  • 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.

On this page