Skip to content

Migrating from class-variance-authority

The concepts are the same, so most recipes move over with a few mechanical changes:

class-variance-authority class-recipe
cva(base, config) cva({ base, ...config })
cva(base), without variants cva({ base, variants: {} }), since variants is required
Arrays or objects of classes One string wherever the config takes classes; cx(...) turns an array or an object into one
An option whose classes are null An option whose classes are ""
{ intent: "primary", class: "..." }, or className { variants: { intent: "primary" }, className: "..." }
class or className prop className prop, which takes one string and ignores any other value; cx(...) turns an array or an object into one
VariantProps<typeof button> VariantsOf<typeof button>, from @lynstack/class-recipe, in which a required variant stays required
twMerge(button(props)) cva from createRecipes({ join: twMerge })
A cn helper, twMerge(clsx(...)) cx from createRecipes({ join: twMerge })
cn, imported from "cn" cx from createRecipes({ join: cn })
cx, which is clsx cx
Beforeclass-variance-authority0.7.1
import { cva } from "class-variance-authority";
export const button = cva("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" },
});

class-variance-authority keeps every class, as class-recipe does by default. To merge classes with tailwind-merge, as twMerge(button(props)) or a cn helper does, 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 } = createRecipes({ join: twMerge });

The cx it returns replaces a cn helper: it joins its inputs as clsx does, then merges them. To keep the imports of your components, create the functions in the module that held your helper, such as src/lib/utils.ts, export cx under its name, and import cva from there too:

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

If you use the cn package, pass it as the join under another name, since the module exports its own cn: import { cn as merge } from "cn", then createRecipes({ join: merge }). Components that import cn from "cn" import it from that module instead.

A recipe created with the join merges the classes of every call, including a call whose result was not passed to cn before. A component that passes a recipe call to cn passes its other classes as className instead, which the recipe adds last and merges with the join:

  • cn(buttonVariants({ variant, size, className })) becomes buttonVariants({ variant, size, className }).
  • cn(buttonVariants({ variant, size }), "w-full", className) becomes buttonVariants({ variant, size, className: cn("w-full", className) }).

Keeping the cn call gives the same classes, but runs the join on every call, where a recipe call without className reads the cache (see What it costs).

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 class-variance-authority does, default to an option without classes:

Beforeclass-variance-authority0.7.1
import { cva } from "class-variance-authority";
export const badge = cva("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. 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.

When a call omits a boolean variant without a default, class-recipe adds the classes of its false option, and a compound condition on false matches. class-variance-authority adds no classes for it:

Beforeclass-variance-authority0.7.1
import { cva } from "class-variance-authority";
export const input = cva("rounded border", {
variants: {
disabled: { true: "opacity-50", false: "cursor-text" },
},
compoundVariants: [{ disabled: false, class: "bg-white" }],
});
input()→ "rounded border"Differsinput({ disabled: false })→ "rounded border cursor-text bg-white"
Afterclass-recipe1.2.0
import { cva } from "@lynstack/class-recipe";
export const input = cva({
base: "rounded border",
variants: {
disabled: { true: "opacity-50", false: "cursor-text" },
},
compoundVariants: [{ variants: { disabled: false }, className: "bg-white" }],
});
input()→ "rounded border cursor-text bg-white"Differsinput({ disabled: false })→ "rounded border cursor-text bg-white"

To add no classes when a call omits it, as before, declare an option without classes and make it the default, such as disabled: { unset: "", true: "opacity-50", false: "cursor-text" } with defaultVariants: { disabled: "unset" }. The variant still accepts true and false.

class-variance-authority adds no classes for a variant passed as null, even one with a default. The types of class-recipe reject null, and a null from untyped data counts as an omitted prop. To let a component turn a variant off, declare an option without classes, and replace a compound condition on null with one on that option:

Beforeclass-variance-authority0.7.1
import { cva } from "class-variance-authority";
export const button = cva("rounded", {
variants: {
size: { sm: "h-8 px-3", md: "h-10 px-4" },
},
defaultVariants: { size: "md" },
});
button()→ "rounded h-10 px-4"button({ size: null })→ "rounded"
Afterclass-recipe1.2.0
import { cva } from "@lynstack/class-recipe";
export const button = cva({
base: "rounded",
variants: {
size: { none: "", sm: "h-8 px-3", md: "h-10 px-4" },
},
defaultVariants: { size: "md" },
});
button()→ "rounded h-10 px-4"button({ size: "none" })→ "rounded"

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. class-variance-authority matches it only when the prop is omitted and the variant has no default:

Beforeclass-variance-authority0.7.1
import { cva } from "class-variance-authority";
export const alert = cva("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

For a variant without a default, name instead the option without classes that it now defaults to (see above). For a variant with a default, the condition never matched, so delete the compound variant.

A recipe without them is a type error and throws a TypeError when it is created. Pass variants: {}. class-variance-authority ignores the compound variants of a config without variants, so drop them.

The types of class-variance-authority 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.

An empty string adds no classes for the variant

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

class-variance-authority uses the default instead:

Beforeclass-variance-authority0.7.1
import { cva } from "class-variance-authority";
export const button = cva("rounded", {
variants: {
size: { sm: "h-8 px-3", md: "h-10 px-4" },
},
defaultVariants: { size: "md" },
});
button({ size: "" })→ "rounded h-10 px-4"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: "" })→ "rounded"Differs

A compound condition names only declared variants

Section titled “A compound condition names only declared variants”

class-variance-authority matches a condition on a prop that is not a variant when a call passes that prop, as a component that spreads its props into the recipe does. class-recipe ignores such a condition. Declare the prop as a variant whose options add no classes, and the condition matches as before:

Beforeclass-variance-authority0.7.1
import { cva } from "class-variance-authority";
export const button = cva("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"

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. class-variance-authority compares the condition with the prop as it is, so it matches only the same type:

Beforeclass-variance-authority0.7.1
import { cva } from "class-variance-authority";
export const toggle = cva("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 shadow-inner"toggle({ level: 2 })→ "rounded bg-white text-base font-medium"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"toggle({ level: 2 })→ "rounded bg-white text-base font-medium"toggle({ pressed: "true" })→ "rounded bg-gray-900 text-sm shadow-inner"Differstoggle({ level: "2" })→ "rounded bg-white text-base font-medium"Differs