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 |
Recipes
Section titled “Recipes”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" },});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”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:
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"Differsimport { 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"DiffersThe 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:
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
elevationbelow, and the names of the theme keys it adds as its second. A group or a key that tailwind-merge defines, such asshadoworspacing, needs none. - Move the settings of class groups that the config gives at its top
level, such as
classGroupsortheme, underextend. - Leave out
prefix: tailwind-variants 3 ignores it, and tailwind-merge applies it, which changes the classes it merges.
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.
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" });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.
Extending a recipe
Section titled “Extending a recipe”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:
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" },});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
baseyourself, the classes of the recipe it extends first. - Spread the shared
compoundVariantsfirst 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:
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"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:
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" }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" }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 tailwind-variants does, default to an option without classes:
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"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:
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"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"A recipe without classes returns ""
Section titled “A recipe without classes returns ""”tailwind-variants returns undefined when a call adds no classes, and so
do a slot without classes, cn, and cx:
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"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:
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"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 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: {}.
A recipe has only variantKeys
Section titled “A recipe has only variantKeys”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[].
Behaviors that change with untyped data
Section titled “Behaviors that change with untyped data”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.
null counts as an omitted prop
Section titled “null counts as an omitted prop”tailwind-variants adds no classes for a variant passed as null, even
one with a default:
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"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: null })→ "rounded h-10 px-4"DiffersAn 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:
import { tv } from "tailwind-variants";
export const stack = tv({ base: "flex", variants: { gap: { false: "gap-4", tight: "gap-1" }, },});stack({ gap: "" })→ "flex gap-4"Differsimport { 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"DiffersA 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:
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"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"DiffersName 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:
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"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"Differstoggle({ level: "2" })→ "rounded bg-white text-base font-medium"Differs