vetka-variants
Class variants with inheritance. Describe a component's classes as a base, variants, default variants and compound variants, then build new functions on existing ones with extends.
vetka(config) returns a function that maps variant props to a class string, the model known from class-variance-authority. What it adds is inheritance: a config can extend one or several existing functions, add or replace their options and change their defaults, and the types are inferred from the merged result. The output is a plain string, so it works with Tailwind CSS or any other class-based CSS, in any framework.
Installation
The package is published as vetka-variants; the function it exports is vetka.
npm install vetka-variants- TypeScript 5.4 or newer is required for the types.
- One runtime dependency, clsx, installed with the package.
- ESM and CommonJS builds, with type declarations.
- About 1 kB minified and gzipped, plus clsx.
Quick start
Describe the classes of a component in a config and pass it to vetka.
import { vetka } from "vetka-variants";
export const button = vetka({
base: "inline-flex items-center font-medium",
variants: {
intent: {
primary: "bg-blue-600 text-white",
secondary: "bg-zinc-100 text-zinc-900",
},
size: {
sm: "h-8 px-3 text-sm rounded",
md: "h-10 px-4 text-sm rounded-md",
},
disabled: { true: "opacity-50 cursor-not-allowed" },
},
defaultVariants: { intent: "primary", size: "md", disabled: false },
compoundVariants: [
{ intent: "primary", disabled: false, class: "hover:bg-blue-700" },
{ intent: "secondary", disabled: false, class: "hover:bg-zinc-200" },
],
});The result is a function. Call it with variant props to get the class string:
button();
// "inline-flex items-center font-medium bg-blue-600 text-white h-10 px-4 text-sm rounded-md hover:bg-blue-700"
button({ intent: "secondary", size: "sm" });
// "inline-flex items-center font-medium bg-zinc-100 text-zinc-900 h-8 px-3 text-sm rounded hover:bg-zinc-200"
button({ disabled: true, class: "mt-2" });
// "inline-flex items-center font-medium bg-blue-600 text-white h-10 px-4 text-sm rounded-md opacity-50 cursor-not-allowed mt-2"baseholds the classes every call returns.variantsare named sets of options. A prop with the name of a variant selects one option. See Variants.defaultVariantsname the option to use when the prop is not passed. See Default variants.compoundVariantsadd classes for a combination of options. Here the hover colour depends on the intent and applies only while the button is not disabled. See Compound variants.classandclassNameare props for extra classes. They are appended at the end.
A new function can start from an existing one. extends inherits everything from button, and the rest of the config states what is different:
const dangerButton = vetka({
extends: button,
variants: { intent: { danger: "bg-red-600 text-white" } },
defaultVariants: { intent: "danger" },
compoundVariants: [
{ intent: "danger", disabled: false, class: "hover:bg-red-700" },
],
});
dangerButton();
// "inline-flex items-center font-medium bg-red-600 text-white h-10 px-4 text-sm rounded-md hover:bg-red-700"
dangerButton({ intent: "secondary", size: "sm" });
// "inline-flex items-center font-medium bg-zinc-100 text-zinc-900 h-8 px-3 text-sm rounded hover:bg-zinc-200"Extending has the rules. The return value is always a plain string: vetka joins class names in a fixed order and does not resolve conflicting utilities, which is the subject of Class order and conflicts.
Variants
A variant is a named set of options, and each option is a class string. The keys of the options decide what the prop accepts:
| Option keys | The prop accepts |
|---|---|
| Strings | One of the keys, such as "muted" |
true, false | A boolean |
| Numbers | One of the numbers, such as 1 |
Every variant prop is optional. A variant that has no default and receives no prop adds no class, as the last call shows.
A boolean variant may declare only one of the two keys, like disabled in Quick start. The prop still accepts both values, and the one without a key adds no class.
const heading = vetka({
base: "font-semibold tracking-tight",
variants: {
level: { 1: "text-4xl", 2: "text-2xl", 3: "text-xl" },
tone: { default: "text-zinc-900", muted: "text-zinc-500" },
truncate: { true: "truncate", false: "break-words" },
},
});
heading({ level: 1, tone: "muted" });
// "font-semibold tracking-tight text-4xl text-zinc-500"
heading({ level: 2, truncate: true });
// "font-semibold tracking-tight text-2xl truncate"
heading({ truncate: false });
// "font-semibold tracking-tight break-words"
heading();
// "font-semibold tracking-tight"The false option applies only when false is passed or set in defaultVariants. A missing prop is not treated as false: heading() returns neither truncate nor break-words.
Default variants
defaultVariants sets the option a variant uses when a call does not pass it. The button from Quick start defaults size to md:
button({ size: "sm" });
// "inline-flex items-center font-medium bg-blue-600 text-white h-8 px-3 text-sm rounded hover:bg-blue-700"
button({ size: undefined });
// "inline-flex items-center font-medium bg-blue-600 text-white h-10 px-4 text-sm rounded-md hover:bg-blue-700"
button({ size: null });
// "inline-flex items-center font-medium bg-blue-600 text-white hover:bg-blue-700"- A value replaces the default.
undefined, or a missing key, keeps the default, so a component can forward its optional props unchanged. A nested selection is the exception, because there the keys themselves count: see Nested variants.nullremoves the default. No option of that variant is selected.
Defaults take part in compound matching. The third call still returns the hover class, because intent and disabled keep their defaults.
Compound variants
An entry of compoundVariants holds conditions and a class. The class is added when every condition matches the selection, which is the default variants with the props of the call applied on top.
const badge = vetka({
base: "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
variants: {
tone: {
neutral: "bg-zinc-100 text-zinc-700",
success: "bg-emerald-100 text-emerald-700",
danger: "bg-red-100 text-red-700",
},
outline: { true: "ring-1 ring-inset" },
},
defaultVariants: { tone: "neutral" },
compoundVariants: [
{ outline: true, tone: "neutral", class: "ring-zinc-300" },
{ outline: true, tone: ["success", "danger"], class: "ring-current" },
],
});
badge({ outline: true });
// "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-zinc-100 text-zinc-700 ring-1 ring-inset ring-zinc-300"
badge({ outline: true, tone: "danger" });
// "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-red-100 text-red-700 ring-1 ring-inset ring-current"
badge({ tone: "danger" });
// "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-red-100 text-red-700"- A condition with a single value matches when that option is selected. A condition with an array matches when any of the listed options is selected.
- Defaults count.
badge({ outline: true })matches the first entry through the defaulttone. - A condition on a variant that has no selected value does not match. This includes
false: thebuttonin Quick start setsdisabled: falseindefaultVariantsso that its hover entries match when the prop is not passed. - Every matching entry adds its class, in the order of the array.
- An entry takes
class.classNameis not accepted here.
Extending
extends takes one vetka function or an array of them. The new function inherits their base classes, variants, default variants and compound variants, and applies its own config on top. The parents are not modified.
One parent
dangerButton in Quick start shows three things a child can do. It adds an option to an inherited variant, it changes an inherited default, and it adds a compound variant that refers to disabled, a variant only the parent declares.
A child can also replace an option. The new class string takes the place of the parent's; the two are not joined.
const compactButton = vetka({
extends: button,
variants: {
size: {
sm: "h-7 px-2 text-xs rounded", // replaces the parent's sm
xs: "h-6 px-1.5 text-xs rounded", // new
},
},
});
compactButton({ size: "sm" });
// "inline-flex items-center font-medium bg-blue-600 text-white h-7 px-2 text-xs rounded hover:bg-blue-700"
compactButton({ size: "md" });
// "inline-flex items-center font-medium bg-blue-600 text-white h-10 px-4 text-sm rounded-md hover:bg-blue-700"
button({ size: "sm" });
// "inline-flex items-center font-medium bg-blue-600 text-white h-8 px-3 text-sm rounded hover:bg-blue-700"md is inherited, and the last call shows that button itself is unchanged.
What is inherited
| Part | How parent and child combine |
|---|---|
base | Every base is kept. The child's comes first, then the parents'. |
variants | Variant names are combined. When both sides declare a variant, its options are combined, and for an option with the same name the child's class replaces the parent's. |
defaultVariants | Merged per variant. The child's value wins, and null removes an inherited default. |
compoundVariants | The parents' entries come first, then the child's. An inherited entry cannot be removed. |
Several parents
With an array of parents, each one can contribute a separate concern. Here input takes its focus ring from one function and its box and sizes from another:
const focusRing = vetka({
base: "outline-none focus-visible:ring-2",
variants: {
ring: {
brand: "focus-visible:ring-blue-500",
danger: "focus-visible:ring-red-500",
},
},
defaultVariants: { ring: "brand" },
});
const control = vetka({
base: "rounded-md border text-sm",
variants: {
size: { sm: "h-8 px-2", md: "h-10 px-3", lg: "h-12 px-4" },
},
defaultVariants: { size: "md" },
});
const input = vetka({
extends: [focusRing, control],
base: "block w-full placeholder:text-zinc-400",
variants: {
invalid: {
true: "border-red-500 text-red-900",
false: "border-zinc-300",
},
},
defaultVariants: { invalid: false },
});
input();
// "block w-full placeholder:text-zinc-400 rounded-md border text-sm outline-none focus-visible:ring-2 focus-visible:ring-blue-500 h-10 px-3 border-zinc-300"
input({ size: "lg", invalid: true, ring: "danger" });
// "block w-full placeholder:text-zinc-400 rounded-md border text-sm outline-none focus-visible:ring-2 focus-visible:ring-red-500 h-12 px-4 border-red-500 text-red-900"When two parents hold the same option or the same default, the later parent wins:
const first = vetka({
base: "first",
variants: { size: { sm: "first-sm", md: "first-md" } },
defaultVariants: { size: "sm" },
});
const second = vetka({
base: "second",
variants: { size: { sm: "second-sm", lg: "second-lg" } },
defaultVariants: { size: "lg" },
});
const both = vetka({ extends: [first, second] });
both();
// "second first second-lg"
both({ size: "sm" });
// "second first second-sm"
both({ size: "md" });
// "second first first-md"second wins the default and the sm option, and md still comes from first. The function's own config wins over every parent. In the output the base classes are listed the other way round, last parent first (second first); Class order and conflicts lists the full order.
A parent counts with everything it has resolved, including what it inherits from its own parents. A later parent therefore also wins with an option or a default that it only inherits. Put the parent whose values should win last, or state them again in the new function.
Chains and shared ancestors
A function that extends others can be extended in turn:
const codeInput = vetka({
extends: input,
base: "text-center font-mono tracking-widest",
defaultVariants: { size: "lg" },
});
codeInput();
// "text-center font-mono tracking-widest block w-full placeholder:text-zinc-400 rounded-md border text-sm outline-none focus-visible:ring-2 focus-visible:ring-blue-500 h-12 px-4 border-zinc-300"When an ancestor is reached through more than one parent, its base classes appear once:
const select = vetka({
extends: [focusRing, control],
base: "appearance-none cursor-pointer",
});
const combobox = vetka({ extends: [input, select], base: "relative" });
combobox();
// "relative appearance-none cursor-pointer rounded-md border text-sm outline-none focus-visible:ring-2 block w-full placeholder:text-zinc-400 focus-visible:ring-blue-500 h-10 px-3 border-zinc-300"focusRing and control are parents of both input and select. Their bases, outline-none focus-visible:ring-2 and rounded-md border text-sm, are in the result once.
Only base classes are treated this way. Options and defaults follow the rule for several parents: with extends: [codeInput, select] the default size is md, because select, the later parent, inherits md from control.
The resolved config
Every vetka function exposes the merged result as config:
dangerButton.config.defaultVariants;
// { intent: "danger", size: "md", disabled: false }
dangerButton.config.variants?.intent;
// { primary: "bg-blue-600 text-white", secondary: "bg-zinc-100 text-zinc-900", danger: "bg-red-600 text-white" }variants, defaultVariants and compoundVariants hold the merged values. base holds the function's own base only, and extends holds the parents as they were passed. Treat config as read-only.
Nested variants
An option can hold further variants in place of a class string. Use this when some variants exist only under one option.
In this card, layout has two options, stack and row. Each is a branch with variants of its own: both have gap, and only row has align.
A branch is selected with an object that follows the same path, such as layout: { row: { align: "center" } }. defaultVariants and the conditions of compoundVariants use the same shape.
TypeScript follows the tree: align under stack is a type error, and so is layout: "row", because row is a branch and not a class string.
const card = vetka({
base: "rounded-xl border bg-white",
variants: {
layout: {
stack: {
gap: {
sm: "flex flex-col gap-2",
lg: "flex flex-col gap-6",
},
},
row: {
gap: {
sm: "flex flex-row gap-2",
lg: "flex flex-row gap-6",
},
align: {
start: "items-start",
center: "items-center",
},
},
},
},
defaultVariants: { layout: { stack: { gap: "sm" } } },
compoundVariants: [
{
layout: { row: { gap: "lg", align: "center" } },
class: "min-h-16",
},
],
});card();
// "rounded-xl border bg-white flex flex-col gap-2"
card({ layout: { stack: { gap: "lg" } } });
// "rounded-xl border bg-white flex flex-col gap-6"
card({ layout: { row: { gap: "lg", align: "center" } } });
// "rounded-xl border bg-white flex flex-row gap-6 items-center min-h-16"
card({ layout: { row: { align: "start" } } });
// "rounded-xl border bg-white items-start"
card({ layout: null });
// "rounded-xl border bg-white"Defaults inside a branch
When the props and the default both hold an object at the same position, the keys of the props' object decide what happens:
- If every key also exists in the default, the two are merged.
stack: { gap: "lg" }replaces the defaultgapand would keep any other default understack. - If any key is missing from the default, the props' object replaces the default at that position. Selecting
rowdrops thestackdefault, androwhas no defaults of its own, so the fourth call returnsitems-startand nothing else forlayout.
The rule looks at keys only, so it also applies to the variants inside one branch: if only some of them have a default, passing one of the others replaces the defaults of that branch. Give every variant of a branch a default, or none of them. The same rule applies when a child's defaultVariants meet a parent's.
A key counts even when its value is undefined. card({ layout: { row: undefined } }) returns no layout class, because row is a key the default does not have. A component that forwards optional props into a nested selection should leave out the keys that are undefined.
Strings and branches side by side
The options of one variant can mix class strings and branches. A string option is selected by name and a branch by object:
const iconButton = vetka({
base: "inline-flex items-center justify-center",
variants: {
size: {
sm: "h-8 px-3",
md: "h-10 px-4",
icon: {
shape: {
square: "h-10 w-10 rounded-md",
circle: "h-10 w-10 rounded-full",
},
},
},
},
defaultVariants: { size: "md" },
});
iconButton({ size: "sm" });
// "inline-flex items-center justify-center h-8 px-3"
iconButton({ size: { icon: { shape: "circle" } } });
// "inline-flex items-center justify-center h-10 w-10 rounded-full"With extends, the options of a variant are merged one level deep. A child that adds a new branch next to the parent's keeps the parent's branches. A child that declares a branch the parent already has replaces that branch as a whole.
Class order and conflicts
The class string is assembled in five parts, always in this order:
| Part | Order inside the part |
|---|---|
| 1. Base classes | The function's own base, then its parents from last to first. Each parent is followed by its own parents. A function reached twice is included once. |
| 2. Variant classes | Variants that are keys of the merged defaultVariants come first, in the order of those keys: the parents' keys, first parent first, then the function's own. A key keeps its place when a child changes its value or sets it to null. The remaining variants follow in the order the props were passed. |
| 3. Compound classes | The order of the merged array: the parents' entries, first parent first, then the function's own. |
4. class | |
5. className |
The order in which variants are declared in variants has no effect. One call shows all five parts:
const p1 = vetka({
base: "p1-base",
variants: { a: { on: "p1-variant" } },
defaultVariants: { a: "on" },
compoundVariants: [{ a: "on", class: "p1-compound" }],
});
const p2 = vetka({
base: "p2-base",
variants: { b: { on: "p2-variant" } },
defaultVariants: { b: "on" },
compoundVariants: [{ b: "on", class: "p2-compound" }],
});
const child = vetka({
extends: [p1, p2],
base: "child-base",
variants: { c: { on: "child-variant" } },
defaultVariants: { c: "on" },
compoundVariants: [{ c: "on", class: "child-compound" }],
});
child({ class: "class", className: "className" });
// "child-base p2-base p1-base p1-variant p2-variant child-variant p1-compound p2-compound child-compound class className"Base classes run from the most specific function to the least specific: child, last parent, first parent. Compound classes run the other way: first parent, last parent, child. Here the variant classes do the same, because every function sets a default for its own variant. In general a variant class takes the position of its variant in the merged defaultVariants, whichever function supplies the class: a child that replaces an option of a parent does not move it.
No conflict resolution
vetka joins strings. It does not remove duplicates, and it does not know that two utilities set the same CSS property.
const pill = vetka({
base: "rounded-full px-3 py-1 text-sm",
variants: { size: { lg: "px-5 py-2 text-base" } },
});
pill({ size: "lg", class: "px-8" });
// "rounded-full px-3 py-1 text-sm px-5 py-2 text-base px-8"px-3, px-5 and px-8 are all in the result. Which one the browser applies depends on the order of the rules in the stylesheet, not on the order of the names in the class attribute.
Using tailwind-merge
To make the later class win, pass the result through tailwind-merge:
import { twMerge } from "tailwind-merge";
twMerge(pill({ size: "lg", class: "px-8" }));
// "rounded-full py-2 text-base px-8"With the order above, later means: variants over base, compounds over variants, class and className over everything else.
A child's base comes before its parents' bases. After tailwind-merge, a utility in the parent's base therefore wins over a conflicting utility in the child's base.
const widePill = vetka({ extends: pill, base: "px-6" });
widePill();
// "px-6 rounded-full px-3 py-1 text-sm"
twMerge(widePill());
// "rounded-full px-3 py-1 text-sm"Two patterns avoid this. The first leaves the parent as it is: put the override in a compound variant without conditions. Such an entry always matches, and its class comes after the base and variant classes.
const widePill = vetka({
extends: pill,
compoundVariants: [{ class: "px-6" }],
});
widePill();
// "rounded-full px-3 py-1 text-sm px-6"
twMerge(widePill());
// "rounded-full py-1 text-sm px-6"It also comes after the parent's variant classes, so after tailwind-merge it overrides px-5 of size: "lg" as well. A class passed at the call site still wins.
The second pattern removes the conflict. Keep classes that a child may change out of base, put them in a variant, and let the child change the default or replace the option:
const pill = vetka({
base: "rounded-full py-1 text-sm",
variants: { padding: { normal: "px-3", wide: "px-6" } },
defaultVariants: { padding: "normal" },
});
const widePill = vetka({
extends: pill,
defaultVariants: { padding: "wide" },
});
widePill();
// "rounded-full py-1 text-sm px-6"Nothing conflicts here, with or without tailwind-merge.
The order of variant classes matters under tailwind-merge in the same way. When two variants without defaults set the same property, the one passed later in the props wins, so two call sites can get different results. Give both a default to fix their order; a default of null fixes the order without selecting an option.
TypeScript
The types need TypeScript 5.4 or newer. They are inferred from the config, so there are no type arguments to write. For a function with extends, the inferred variants are the merged ones: props, defaultVariants and compoundVariants are all checked against the parents' variants together with the function's own.
dangerButton({ intent: "ghost" });
// Error: Type '"ghost"' is not assignable to type '"primary" | "secondary" | "danger" | null | undefined'.
vetka({ extends: button, defaultVariants: { size: "xl" } });
// Error: Type '"xl"' is not assignable to type '"sm" | "md" | null | undefined'.
vetka({ extends: button, compoundVariants: [{ size: "xl", class: "underline" }] });
// Error: Type '"xl"' is not assignable to type '"sm" | "md" | readonly ("sm" | "md")[] | undefined'.VariantPropsOf gives the props type of an existing function:
import type { VariantPropsOf } from "vetka-variants";
type DangerButtonProps = VariantPropsOf<typeof dangerButton>;
// {
// class?: string;
// className?: string;
// intent?: "primary" | "secondary" | "danger" | null;
// size?: "sm" | "md" | null;
// disabled?: boolean | null;
// }The type includes class and className. Every variant is optional and accepts null.
Write option maps as object literals. When the options are typed as a dictionary, such as Record<string, string>, the keys are lost and the prop accepts any string or number.
Exported types
Schema below stands for the type of a variants object, for example { size: { sm: string; md: string } }.
| Type | Use |
|---|---|
VariantPropsOf<typeof fn> | The props of an existing vetka function. |
VariantProps<Schema> | The same props for a schema written by hand. |
VariantSchema | The shape of a variants object. |
DefaultVariants<Schema> | The shape of defaultVariants. |
CompoundVariant<Schema> | One entry of compoundVariants. |
IClassProp | The class and className props. |
IVetkaConfig<Schema, Parents> | A config object, for typing one before it is passed to vetka. Parents is the type of extends, such as typeof button; leave it out for a config without extends. |
IVetkaFn<Schema> | A vetka function with a known schema. |
IAnyVetkaFn | Any vetka function, whatever its variants. Use it as a constraint, as in <F extends IAnyVetkaFn>. A value annotated with it no longer carries its variants, so pass parents to extends directly or as an as const tuple. |
Vetka | The type of vetka itself, for wrappers. |
Using with React
The component below wraps the button from Quick start. Its disabled variant has the same name as an HTML attribute, which is the case that needs care.
import type { ButtonHTMLAttributes } from "react";
import type { VariantPropsOf } from "vetka-variants";
import { button } from "./button.styles";
type ButtonVariants = Omit<VariantPropsOf<typeof button>, "class">;
type ButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, keyof ButtonVariants> &
ButtonVariants;
export function Button({ intent, size, disabled, className, ...rest }: ButtonProps) {
return (
<button
className={button({ intent, size, disabled, className })}
disabled={disabled ?? undefined}
{...rest}
/>
);
}Two uses, each with the markup it renders:
<Button intent="secondary" size="sm" className="mt-4" type="submit">Save</Button>
// <button class="inline-flex items-center font-medium bg-zinc-100 text-zinc-900 h-8 px-3 text-sm rounded hover:bg-zinc-200 mt-4" type="submit">Save</button>
<Button disabled>Save</Button>
// <button class="inline-flex items-center font-medium bg-blue-600 text-white h-10 px-4 text-sm rounded-md opacity-50 cursor-not-allowed" disabled="">Save</button>Three details make the props type work:
Omit<..., keyof ButtonVariants>removes the HTML attributes that share a name with a variant before the two types are combined. The shorter form, an interface that extends bothButtonHTMLAttributes<HTMLButtonElement>andVariantPropsOf<typeof button>, does not compile when a variant has the name of an attribute with a different type:disabledon a button,sizeon an input,coloron any element.Omit<..., "class">drops vetka'sclassprop and keepsclassName. Ifclassstays in the props type, it travels in...restto the DOM element, and React reports an invalid DOM property.disabled ?? undefinedis needed because the variant acceptsnulland the DOM attribute does not.
Invalid values are reported where the component is used: <Button size="xl" /> is a type error.
API reference
vetka is the only runtime export. It takes a config and returns a function.
Config
Every key is optional.
- Name
base- Type
- string
- Description
Classes included in every result.
- Name
variants- Type
- VariantSchema
- Description
Variant names mapped to their options. An option is a class string or a nested map of further variants.
- Name
defaultVariants- Type
- DefaultVariants
- Description
The option each variant uses when a call does not pass it. It has the shape of the variant props.
- Name
compoundVariants- Type
- CompoundVariant[]
- Description
Entries made of conditions and a
classstring. A condition is an option, an array of options, or a nested object of conditions.
- Name
extends- Type
- IAnyVetkaFn | IAnyVetkaFn[]
- Description
One vetka function, or an array of them, to inherit from.
Returned function
- Name
fn(props)- Type
- string
- Description
Returns the class string.
propscan be omitted ornull.
- Name
fn.config- Type
- object
- Description
The resolved config: the merged
variants,defaultVariantsandcompoundVariants, the function's ownbase, andextendsas it was passed.
- Name
fn._id- Type
- number
- Description
An internal identifier. vetka uses it to recognise a function that is reached through more than one parent, so that its base classes are added once.
Props
- Name
[variant]- Type
- option | null
- Description
One prop for each variant, all optional.
undefinedkeeps the default andnullremoves it. A nested variant takes an object; Nested variants explains how its keys meet the defaults.
- Name
class- Type
- string
- Description
Extra classes, appended after the compound classes.
- Name
className- Type
- string
- Description
Extra classes, appended after
class. Both props can be used in one call.
Comparison
The table compares vetka with class-variance-authority 0.7.1, the cva 1.0 beta (1.0.0-beta.12) and tailwind-variants 3.3.1, as of October 2026.
| vetka | class-variance-authority 0.7 | cva 1.0 beta | tailwind-variants 3 | |
|---|---|---|---|---|
| Base classes | base key | First argument | base key | base key |
| Inheritance | extends key | None built in | composes key | extend key |
| Parents per definition | One or several | Not applicable | One or several | One |
| Option declared again by the child | Replaces the parent's class | Not applicable | Added next to the parent's class | Added next to the parent's class; the built-in merge then removes conflicting utilities |
| Base of an ancestor shared by two parents | Emitted once | Not applicable | Emitted once per path | Not applicable |
| Order of base classes | Child, then parents | Not applicable | Parents, then own | Parent, then child |
| Nested variants | Yes | No | No | No |
| Slots | No | No | No | Yes |
| Conflict resolution | No | No | Not by default; the class joiner is configurable | Built in |
null removes a default | Yes | Yes | No | Yes |
| Class values | Strings | clsx values | clsx values | Strings, arrays, objects |
For one parent, one added option and one changed default, vetka, composes in the cva beta and extend in tailwind-variants produce the same set of classes. The differences in the table appear when an option is declared again, when two parents share a variant, and when an ancestor is reached through two parents.
Another library is the better choice in these cases:
- You need slots or built-in Tailwind conflict resolution. tailwind-variants has both.
- You need neither inheritance nor nested variants. class-variance-authority is smaller, stable and far more widely used.
- You want the class-variance-authority model with composition and a configurable class joiner, and a beta that requires TypeScript 6 is acceptable. That is the cva 1.0 beta.
vetka fits a hierarchy of related functions in which children replace options and defaults of their parents, several parents feed one child, or variants are nested.
Coming from class-variance-authority
The config has the same shape. The base moves from the first argument into the base key:
-import { cva } from "class-variance-authority";
+import { vetka } from "vetka-variants";
-const link = cva("underline underline-offset-2", {
+const link = vetka({
+ base: "underline underline-offset-2",
variants: {
tone: { default: "text-blue-600", muted: "text-zinc-500" },
},
defaultVariants: { tone: "default" },
});Other differences to expect:
- Class values are strings. Arrays and other clsx values are not accepted in
base, in options, in theclassof a compound entry, or in theclassandclassNameprops. - Compound entries take
classonly. - Variant classes follow the defaults and the props, as described in Class order and conflicts. class-variance-authority emits them in the order the variants are declared.
- The props type is
VariantPropsOf<typeof fn>, and it includesclassandclassName.VariantPropsof class-variance-authority leaves them out. vetka also exports a type namedVariantProps, but it takes a schema, not a function: where class-variance-authority code hasVariantProps<typeof fn>, useVariantPropsOf<typeof fn>. - There is no
cxexport. Import clsx directly.
Limitations
What vetka does not do:
- No slots. One call returns one string. For a component with several parts, define one function per part.
- No responsive variants. An object passed as a variant value is read as a nested selection.
- No conflict resolution and no de-duplication. The class joiner is not configurable; wrap the call as shown in Class order and conflicts.
- Strings only.
base, options, theclassof a compound entry and theclassandclassNameprops are strings. - No required variants. Every variant prop is optional.
- No runtime validation. TypeScript reports unknown variant names and values. At runtime they add no class and do not throw.
- No removal of inherited classes. A child cannot drop a base class or a compound entry of a parent. It can replace the class of an option, and change or remove a default.
Comparison names the libraries that have slots and built-in conflict resolution.