Skip to content

Migrating from tailwind-variants

A recipe of tailwind-variants without slots becomes a cva recipe, and one with slots, or that extends a recipe with slots, becomes an sva recipe. Most of the config moves over with a few mechanical changes:

tailwind-variants class-recipe
tv(config) cva(config), or sva(config) with slots
tv({ base }), without variants cva({ base, variants: {} }), since variants is required
Arrays of classes, including shared arrays spread into them One string wherever the config takes classes; cx(...) turns an array into one
An option whose classes are null or false An option whose classes are ""
{ size: "sm", class: "..." }, or className { variants: { size: "sm" }, className: "..." }
class or className prop className prop, which takes one string and ignores any other value; cx(...) turns an array into one
slots: { base: "...", title: "..." } slots: ["root", "title"] and base: { root: "...", title: "..." }
In a slot recipe, base, an option, or a compound variant’s class given as a string The same classes under the slot that tailwind-variants names base, such as { root: "..." }
{ size: "sm", class: { title: "..." } } { variants: { size: "sm" }, classNames: { title: "..." } }
compoundSlots A compound variant that gives the same classes to each slot in classNames, listed after the other compound variants
title({ class: "..." }) card({ classNames: { title: "..." } })
extend A shared config object, spread into each recipe
VariantProps<typeof button> VariantsOf<typeof button>, from @lynstack/class-recipe, in which a required variant stays required
tv, cn(...), and cnMerge(...)(config), which merge cva, sva, and cx(...) from createRecipes({ join: twMerge })
createTV({ twMergeConfig }), or a tv of your own that passes one createRecipes with extendTailwindMerge, as in Merging classes
tailwind-variants/lite, its cn(...)(), and cx, which do not merge cva, sva, and cx from @lynstack/class-recipe
Beforetailwind-variants3.3.1
import { tv } from "tailwind-variants";
export const button = tv({
base: "rounded-md font-medium",
variants: {
intent: { primary: "bg-blue-600 text-white", secondary: "bg-gray-100" },
size: { sm: "h-8 px-3", md: "h-10 px-4" },
},
compoundVariants: [{ intent: "primary", size: "md", class: "shadow-sm" }],
defaultVariants: { intent: "primary", size: "md" },
});
Afterclass-recipe1.2.0
import { cva } from "@lynstack/class-recipe";
export const button = cva({
base: "rounded-md font-medium",
variants: {
intent: { primary: "bg-blue-600 text-white", secondary: "bg-gray-100" },
size: { sm: "h-8 px-3", md: "h-10 px-4" },
},
compoundVariants: [
{ variants: { intent: "primary", size: "md" }, className: "shadow-sm" },
],
defaultVariants: { intent: "primary", size: "md" },
});

tv resolves conflicting classes by default, as tailwind-merge does, and class-recipe keeps every class by default. A recipe that relies on the merge, such as one with p-4 in base and p-2 in an option, keeps both classes with the default join, and the stylesheet decides which one wins:

Beforetailwind-variants3.3.1
import { tv } from "tailwind-variants";
export const card = tv({
base: "rounded-lg p-4",
variants: {
size: { sm: "p-2", md: "" },
},
});
card({ size: "sm" })→ "rounded-lg p-2"Differs
Afterclass-recipe1.2.0
import { cva } from "@lynstack/class-recipe";
export const card = cva({
base: "rounded-lg p-4",
variants: {
size: { sm: "p-2", md: "" },
},
});
card({ size: "sm" })→ "rounded-lg p-4 p-2"Differs

The merge also drops duplicate classes, and both builds of tailwind-variants collapse extra spaces. The default join keeps the classes as the config writes them, which changes snapshots of class names, but not which classes an element has.

If you used tailwind-variants/lite, or twMerge: false everywhere, nothing was merged: import cva, sva, and cx from @lynstack/class-recipe. You can also design your recipes so that no class needs merging (see Writing conflict-free recipes).

To keep the behavior of tv, install tailwind-merge and create the functions with it once, in a module of your own:

src/lib/recipe.ts
import { createRecipes } from "@lynstack/class-recipe";
import { twMerge } from "tailwind-merge";
export const { cx, cva, sva } = createRecipes({ join: twMerge });

The cx it returns merges as cn does. For a recipe that passed { twMerge: false } or its own twMergeConfig to tv, create another set of functions with createRecipes, with the settings of both configs if your own tv combines them.

If you pass a twMergeConfig, build the join with extendTailwindMerge from the same settings:

  • Give the names of the class groups that the config adds, and that tailwind-merge does not define, as its first type argument, as elevation below, and the names of the theme keys it adds as its second. A group or a key that tailwind-merge defines, such as shadow or spacing, needs none.
  • Move the settings of class groups that the config gives at its top level, such as classGroups or theme, under extend.
  • Leave out prefix: tailwind-variants 3 ignores it, and tailwind-merge applies it, which changes the classes it merges.
src/lib/recipe.ts
import { createRecipes } from "@lynstack/class-recipe";
import { extendTailwindMerge } from "tailwind-merge";
export const { cx, cva, sva } = createRecipes({
join: extendTailwindMerge<"elevation">({
extend: {
classGroups: { elevation: ["elevation-low", "elevation-high"] },
},
}),
});

A call with className or classNames runs the join again, where a call without them reads the cache (see What it costs).

slots lists the names of the slots, and base gives their classes. A slot recipe returns an object of strings, not of functions, so title() becomes title, and the classes that title({ class }) adds go in the call’s classNames. The slot that tailwind-variants names base can keep that name, but another name, such as root, reads better next to the base property.

Beforetailwind-variants3.3.1
import { tv } from "tailwind-variants";
export const card = tv({
slots: { base: "rounded-lg", title: "font-semibold" },
variants: {
size: {
sm: { base: "p-2", title: "text-sm" },
md: { base: "p-4", title: "text-lg" },
},
},
compoundSlots: [{ slots: ["base", "title"], size: "sm", class: "gap-1" }],
defaultVariants: { size: "md" },
});
const { base, title } = card({ size: "sm" });
export const rootClass = base();
export const titleClass = title({ class: "truncate" });
Afterclass-recipe1.2.0
import { sva } from "@lynstack/class-recipe";
export const card = sva({
slots: ["root", "title"],
base: { root: "rounded-lg", title: "font-semibold" },
variants: {
size: {
sm: { root: "p-2", title: "text-sm" },
md: { root: "p-4", title: "text-lg" },
},
},
compoundVariants: [
{
variants: { size: "sm" },
classNames: { root: "gap-1", title: "gap-1" },
},
],
defaultVariants: { size: "md" },
});
const { root, title } = card({ size: "sm", classNames: { title: "truncate" } });
export const rootClass = root;
export const titleClass = title;

A slot function of tailwind-variants can also take variants of its own, as in title({ size: "md" }), which override those of the call. Call the slot recipe again with both instead: card({ ...variants, size: "md" }).title. A component that receives the slots without the variants, such as one that reads them from a context, joins its own classes with cx: cx(slots.title, className) gives the classes that classNames gives. tailwind-variants ignores class and className passed to the slot recipe itself, rather than to a slot, so drop them.

tailwind-variants also returns a base slot that the config does not declare. It holds the classes of the config’s top-level base, unless slots.base replaces them, and of each option or compound variant that gives classes as a string; without them, its function returns undefined. A slot recipe returns only the slots that slots lists, so give such classes to a slot you declare, and drop a base that you read from such a recipe.

A compound slot becomes a compound variant that gives the same classes to each of its slots. tailwind-variants adds the classes of compound slots after those of every compound variant, so list them last. A compound slot without conditions becomes a compound variant with variants: {}, which always matches, rather than classes in base, which come before the variants and so lose to them when classes are merged.

class-recipe has no extend. Keep the config that recipes share in an object declared as const, and spread it into each of them. Without as const, the option in defaultVariants widens to string, which the recipe rejects:

Beforetailwind-variants3.3.1
import { tv } from "tailwind-variants";
export const button = tv({
base: "rounded-md font-medium",
variants: {
size: { sm: "h-8 px-3", md: "h-10 px-4" },
},
defaultVariants: { size: "md" },
});
export const iconButton = tv({
extend: button,
base: "aspect-square",
variants: {
tone: { neutral: "bg-gray-100", danger: "bg-red-600 text-white" },
},
defaultVariants: { tone: "neutral" },
});
Afterclass-recipe1.2.0
import { cva } from "@lynstack/class-recipe";
const buttonConfig = {
base: "rounded-md font-medium",
variants: {
size: { sm: "h-8 px-3", md: "h-10 px-4" },
},
defaultVariants: { size: "md" },
} as const;
export const button = cva(buttonConfig);
export const iconButton = cva({
...buttonConfig,
base: `${buttonConfig.base} aspect-square`,
variants: {
tone: { neutral: "bg-gray-100", danger: "bg-red-600 text-white" },
...buttonConfig.variants,
},
defaultVariants: { ...buttonConfig.defaultVariants, tone: "neutral" },
});

extend merges the two configs: it joins their base classes, adds the classes of an option given again to those of the recipe it extends, merges defaultVariants and slots, and concatenates compoundVariants and compoundSlots. It also lists the variants of the new recipe before those it extends, which decides the order of their classes. A spread replaces each property it gives again, so:

  • Join base yourself, the classes of the recipe it extends first.
  • Spread the shared compoundVariants first into the new array.
  • Declare the new variants before the shared ones, as above.
  • Take a variant that both recipes declare out of the shared variants, which would replace it, and declare it with the new ones, joining the classes of each option that both give:
Beforetailwind-variants3.3.1
import { tv } from "tailwind-variants";
export const button = tv({
base: "rounded-md font-medium",
variants: {
size: { sm: "h-8 px-3", md: "h-10 px-4" },
width: { auto: "w-auto", full: "w-full" },
},
defaultVariants: { size: "md", width: "auto" },
});
export const iconButton = tv({
extend: button,
variants: {
tone: { neutral: "bg-gray-100", danger: "bg-red-600 text-white" },
size: { sm: "text-xs", lg: "h-12 px-6" },
},
defaultVariants: { tone: "neutral" },
});
iconButton({ size: "sm" })→ "rounded-md font-medium bg-gray-100 h-8 px-3 text-xs w-auto"iconButton({ size: "md", tone: "danger" })→ "rounded-md font-medium bg-red-600 text-white h-10 px-4 w-auto"iconButton({ size: "lg", width: "full" })→ "rounded-md font-medium bg-gray-100 h-12 px-6 w-full"
Afterclass-recipe1.2.0
import { cva } from "@lynstack/class-recipe";
const buttonConfig = {
base: "rounded-md font-medium",
variants: {
size: { sm: "h-8 px-3", md: "h-10 px-4" },
width: { auto: "w-auto", full: "w-full" },
},
defaultVariants: { size: "md", width: "auto" },
} as const;
export const button = cva(buttonConfig);
const { size, ...buttonVariants } = buttonConfig.variants;
export const iconButton = cva({
...buttonConfig,
variants: {
tone: { neutral: "bg-gray-100", danger: "bg-red-600 text-white" },
size: { ...size, sm: `${size.sm} text-xs`, lg: "h-12 px-6" },
...buttonVariants,
},
defaultVariants: { ...buttonConfig.defaultVariants, tone: "neutral" },
});
iconButton({ size: "sm" })→ "rounded-md font-medium bg-gray-100 h-8 px-3 text-xs w-auto"iconButton({ size: "md", tone: "danger" })→ "rounded-md font-medium bg-red-600 text-white h-10 px-4 w-auto"iconButton({ size: "lg", width: "full" })→ "rounded-md font-medium bg-gray-100 h-12 px-6 w-full"

A slot recipe extends the same way. Spread its slots into the new list, and join the base classes of each slot that the new recipe gives again. as const also keeps the slot names: where they widen to string, the recipe accepts any slot name in classNames, so a misspelled one goes unnoticed:

Beforetailwind-variants3.3.1
import { tv } from "tailwind-variants";
export const field = tv({
slots: { base: "flex flex-col", label: "text-gray-700" },
variants: {
size: { sm: { label: "text-xs" }, md: {} },
},
defaultVariants: { size: "md" },
});
export const dateField = tv({
extend: field,
slots: { base: "gap-1", calendar: "rounded-lg border" },
});
dateField()→ { base: "flex flex-col gap-1", label: "text-gray-700", calendar: "rounded-lg border" }dateField({ size: "sm" })→ { base: "flex flex-col gap-1", label: "text-gray-700 text-xs", calendar: "rounded-lg border" }
Afterclass-recipe1.2.0
import { sva } from "@lynstack/class-recipe";
const fieldConfig = {
slots: ["root", "label"],
base: { root: "flex flex-col", label: "text-gray-700" },
variants: {
size: { sm: { label: "text-xs" }, md: {} },
},
defaultVariants: { size: "md" },
} as const;
export const field = sva(fieldConfig);
export const dateField = sva({
...fieldConfig,
slots: [...fieldConfig.slots, "calendar"],
base: {
...fieldConfig.base,
root: `${fieldConfig.base.root} gap-1`,
calendar: "rounded-lg border",
},
});
dateField()→ { root: "flex flex-col gap-1", label: "text-gray-700", calendar: "rounded-lg border" }dateField({ size: "sm" })→ { root: "flex flex-col gap-1", label: "text-gray-700 text-xs", calendar: "rounded-lg border" }

Each example below calls the recipe before and after the migration, and shows what it returns.

A variant without a default becomes required

Section titled “A variant without a default becomes required”

A variant without a default must be passed, unless it is a boolean variant, whose only options are true, false, or both: every call that omits it is a type error. Give it a default. To add no classes when it is omitted, as tailwind-variants does, default to an option without classes:

Beforetailwind-variants3.3.1
import { tv } from "tailwind-variants";
export const badge = tv({
base: "rounded px-2",
variants: {
tone: { info: "bg-blue-100", danger: "bg-red-100" },
},
});
badge()→ "rounded px-2"badge({ tone: "danger" })→ "rounded px-2 bg-red-100"
Afterclass-recipe1.2.0
import { cva } from "@lynstack/class-recipe";
export const badge = cva({
base: "rounded px-2",
variants: {
tone: { none: "", info: "bg-blue-100", danger: "bg-red-100" },
},
defaultVariants: { tone: "none" },
});
badge()→ "rounded px-2"badge({ tone: "danger" })→ "rounded px-2 bg-red-100"

Give the new option a name that the variant does not use yet, such as none or unset; it becomes a value that the props of a component accept. In a slot recipe, its classes are {}. An option without classes that the variant declares already works too, unless a compound variant names it, since the condition would then match calls that omit the variant.

If every caller already passes the variant, such as a component that destructures the prop with a default, make that value the default of the recipe instead. Keep the component’s default as well if it reads the prop for anything else, such as a data-variant attribute.

Only a boolean variant falls back to its false option

Section titled “Only a boolean variant falls back to its false option”

tailwind-variants uses the false option of any variant that a call omits and that has no default. In class-recipe, a variant with other options is required instead, so make "false" its default:

Beforetailwind-variants3.3.1
import { tv } from "tailwind-variants";
export const stack = tv({
base: "flex",
variants: {
gap: { false: "gap-4", tight: "gap-1" },
},
});
stack()→ "flex gap-4"stack({ gap: "tight" })→ "flex gap-1"
Afterclass-recipe1.2.0
import { cva } from "@lynstack/class-recipe";
export const stack = cva({
base: "flex",
variants: {
gap: { false: "gap-4", tight: "gap-1" },
},
defaultVariants: { gap: "false" },
});
stack()→ "flex gap-4"stack({ gap: "tight" })→ "flex gap-1"

tailwind-variants returns undefined when a call adds no classes, and so do a slot without classes, cn, and cx:

Beforetailwind-variants3.3.1
import { tv } from "tailwind-variants";
export const label = tv({
variants: {
tone: { plain: "", muted: "text-gray-500" },
},
defaultVariants: { tone: "plain" },
});
label()→ undefinedDifferslabel({ tone: "muted" })→ "text-gray-500"
Afterclass-recipe1.2.0
import { cva } from "@lynstack/class-recipe";
export const label = cva({
variants: {
tone: { plain: "", muted: "text-gray-500" },
},
defaultVariants: { tone: "plain" },
});
label()→ ""Differslabel({ tone: "muted" })→ "text-gray-500"

A compound condition names only declared variants

Section titled “A compound condition names only declared variants”

The types of tailwind-variants accept a compound condition on a prop that is not a variant, which matches when a call passes that prop, as a component that spreads its props into the recipe does. The types of class-recipe reject it. Declare the prop as a variant whose options add no classes, and the condition matches as before:

Beforetailwind-variants3.3.1
import { tv } from "tailwind-variants";
export const button = tv({
base: "px-4",
variants: {
isInGroup: { true: "border-s-0" },
},
compoundVariants: [{ isInGroup: true, isRounded: true, class: "shadow-sm" }],
});
button({ isInGroup: true })→ "px-4 border-s-0"button({ isInGroup: true, isRounded: true })→ "px-4 border-s-0 shadow-sm"
Afterclass-recipe1.2.0
import { cva } from "@lynstack/class-recipe";
export const button = cva({
base: "px-4",
variants: {
isInGroup: { true: "border-s-0" },
isRounded: { true: "" },
},
compoundVariants: [
{ variants: { isInGroup: true, isRounded: true }, className: "shadow-sm" },
],
});
button({ isInGroup: true })→ "px-4 border-s-0"button({ isInGroup: true, isRounded: true })→ "px-4 border-s-0 shadow-sm"

For a prop that is not boolean, declare an option for each value that a condition names, and one more as its default, such as none, so that calls without the prop stay valid.

A recipe without them is a type error and throws a TypeError when it is created. Pass variants: {}.

tailwind-variants also exposes the config of a recipe, such as button.variants and button.slots. Keep the config in an object if you read it. variantKeys is a readonly array, so a function that takes it as K[] needs readonly K[].

The types of tailwind-variants reject the values below, so these changes reach only data that bypasses the types, such as the props of a JavaScript component or the values of a form. The calls in these examples pass such values.

tailwind-variants adds no classes for a variant passed as null, even one with a default:

Beforetailwind-variants3.3.1
import { tv } from "tailwind-variants";
export const button = tv({
base: "rounded",
variants: {
size: { sm: "h-8 px-3", md: "h-10 px-4" },
},
defaultVariants: { size: "md" },
});
button({ size: null })→ "rounded"Differs
Afterclass-recipe1.2.0
import { cva } from "@lynstack/class-recipe";
export const button = cva({
base: "rounded",
variants: {
size: { sm: "h-8 px-3", md: "h-10 px-4" },
},
defaultVariants: { size: "md" },
});
button({ size: null })→ "rounded h-10 px-4"Differs

An empty string adds no classes for the variant

Section titled “An empty string adds no classes for the variant”

tailwind-variants uses the variant’s false option instead, even when the variant has a default:

Beforetailwind-variants3.3.1
import { tv } from "tailwind-variants";
export const stack = tv({
base: "flex",
variants: {
gap: { false: "gap-4", tight: "gap-1" },
},
});
stack({ gap: "" })→ "flex gap-4"Differs
Afterclass-recipe1.2.0
import { cva } from "@lynstack/class-recipe";
export const stack = cva({
base: "flex",
variants: {
gap: { false: "gap-4", tight: "gap-1" },
},
defaultVariants: { gap: "false" },
});
stack({ gap: "" })→ "flex"Differs

A compound condition on undefined matches any option

Section titled “A compound condition on undefined matches any option”

A condition on undefined matches any option, as a variant left out of the condition does. tailwind-variants matches it only when the prop is null or false, or when it is omitted and the variant has no default or a default of false:

Beforetailwind-variants3.3.1
import { tv } from "tailwind-variants";
export const alert = tv({
base: "rounded p-4",
variants: {
tone: { info: "bg-blue-50", danger: "bg-red-50" },
},
compoundVariants: [
{ tone: undefined, class: "border" },
],
defaultVariants: { tone: "info" },
});
alert()→ "rounded p-4 bg-blue-50"Differsalert({ tone: "danger" })→ "rounded p-4 bg-red-50"Differs
Afterclass-recipe1.2.0
import { cva } from "@lynstack/class-recipe";
export const alert = cva({
base: "rounded p-4",
variants: {
tone: { info: "bg-blue-50", danger: "bg-red-50" },
},
compoundVariants: [{ variants: { tone: undefined }, className: "border" }],
defaultVariants: { tone: "info" },
});
alert()→ "rounded p-4 bg-blue-50 border"Differsalert({ tone: "danger" })→ "rounded p-4 bg-red-50 border"Differs

Name the option instead: the option that a variant without a default now defaults to, such as none or false, or false for a variant whose default is false. For a variant with another default, the condition matched only untyped values, so delete the compound variant.

A compound condition matches an option by its name

Section titled “A compound condition matches an option by its name”

A condition on true matches the prop "true", and one on 2 matches the prop "2", as the types of class-recipe allow. Calls with true and 2 match in both libraries. tailwind-variants compares the condition with the prop as it is, so it matches only the same type, and its cache stores 2 and "2" under one key, so a recipe that receives both can return the result of one for the other:

Beforetailwind-variants3.3.1
import { tv } from "tailwind-variants";
export const toggle = tv({
base: "rounded",
variants: {
pressed: { true: "bg-gray-900", false: "bg-white" },
level: { 1: "text-sm", 2: "text-base" },
},
compoundVariants: [
{ pressed: true, class: "shadow-inner" },
{ level: 2, class: "font-medium" },
],
defaultVariants: { pressed: false, level: 1 },
});
toggle({ pressed: "true" })→ "rounded bg-gray-900 text-sm"Differstoggle({ level: "2" })→ "rounded bg-white text-base"Differs
Afterclass-recipe1.2.0
import { cva } from "@lynstack/class-recipe";
export const toggle = cva({
base: "rounded",
variants: {
pressed: { true: "bg-gray-900", false: "bg-white" },
level: { 1: "text-sm", 2: "text-base" },
},
compoundVariants: [
{ variants: { pressed: true }, className: "shadow-inner" },
{ variants: { level: 2 }, className: "font-medium" },
],
defaultVariants: { pressed: false, level: 1 },
});
toggle({ pressed: "true" })→ "rounded bg-gray-900 text-sm shadow-inner"Differstoggle({ level: "2" })→ "rounded bg-white text-base font-medium"Differs