Skip to content

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.

npm package: vetka-variantsRepository: a-omi-io/vetka
  • TypeScript
  • Tailwind CSS
  • class variants

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.

  • 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.

button.styles.ts

The result is a function. Call it with variant props to get the class string:

  • base holds the classes every call returns.
  • variants are named sets of options. A prop with the name of a variant selects one option. See Variants.
  • defaultVariants name the option to use when the prop is not passed. See Default variants.
  • compoundVariants add 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.
  • class and className are 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:

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.

configvariants

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:

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.

configdefaultVariants

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:

  • 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.
  • null removes 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.

configcompoundVariants

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.

  • 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 default tone.
  • A condition on a variant that has no selected value does not match. This includes false: the button in Quick start sets disabled: false in defaultVariants so 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. className is not accepted here.
configextends

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.

md is inherited, and the last call shows that button itself is unchanged.

What is inherited

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:

When two parents hold the same option or the same default, the later parent wins:

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:

When an ancestor is reached through more than one parent, its base classes appear once:

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:

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.

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 default gap and would keep any other default under stack.
  • If any key is missing from the default, the props' object replaces the default at that position. Selecting row drops the stack default, and row has no defaults of its own, so the fourth call returns items-start and nothing else for layout.

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:

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:

The order in which variants are declared in variants has no effect. One call shows all five parts:

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.

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:

With the order above, later means: variants over base, compounds over variants, class and className over everything else.

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.

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:

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.

VariantPropsOf gives the props type of an existing function:

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 } }.

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.

Button.tsx

Two uses, each with the markup it renders:

Three details make the props type work:

  1. 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 both ButtonHTMLAttributes<HTMLButtonElement> and VariantPropsOf<typeof button>, does not compile when a variant has the name of an attribute with a different type: disabled on a button, size on an input, color on any element.
  2. Omit<..., "class"> drops vetka's class prop and keeps className. If class stays in the props type, it travels in ...rest to the DOM element, and React reports an invalid DOM property.
  3. disabled ?? undefined is needed because the variant accepts null and the DOM attribute does not.

Invalid values are reported where the component is used: <Button size="xl" /> is a type error.

functionvetka(config)

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 class string. 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. props can be omitted or null.

  • Name
    fn.config
    Type
    object
    Description

    The resolved config: the merged variants, defaultVariants and compoundVariants, the function's own base, and extends as 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. undefined keeps the default and null removes 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.

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:

Other differences to expect:

  • Class values are strings. Arrays and other clsx values are not accepted in base, in options, in the class of a compound entry, or in the class and className props.
  • Compound entries take class only.
  • 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 includes class and className. VariantProps of class-variance-authority leaves them out. vetka also exports a type named VariantProps, but it takes a schema, not a function: where class-variance-authority code has VariantProps<typeof fn>, use VariantPropsOf<typeof fn>.
  • There is no cx export. 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, the class of a compound entry and the class and className props 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.