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 |
Recipes
Section titled “Recipes”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" },});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" },});Merging classes
Section titled “Merging classes”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:
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:
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 }))becomesbuttonVariants({ variant, size, className }).cn(buttonVariants({ variant, size }), "w-full", className)becomesbuttonVariants({ 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).
Behaviors that change
Section titled “Behaviors that change”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:
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"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.
A boolean variant uses its false option
Section titled “A boolean variant uses its false option”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:
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"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.
null is not an option
Section titled “null is not an option”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:
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"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:
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"Differsimport { 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"DiffersFor 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 needs variants
Section titled “A recipe needs variants”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.
Behaviors that change with untyped data
Section titled “Behaviors that change with untyped data”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:
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"Differsimport { 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"DiffersA 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:
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"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:
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"Differsimport { 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