mrkeyoor.com_
Thu 06 Aug 01:03 UTC
npmWeb Frontendupdated 05 Aug 2026

class-variance-authority

cva turns a variant config object into a typed function that returns a class string. You give it a base class list plus a map of variants (size, intent, disabled), optional defaults, and compound rules for specific combinations; you get back a function whose argument type TypeScript infers from that same config, so the props of your Button and the classes it emits can never drift apart. At runtime it is string picking and joining on top of clsx, about 0.6 KB gzipped, with no framework binding. shadcn/ui put a cva call at the top of nearly every component it ships, which is why the download count is far larger than the star count suggests.

Verdict

The best small answer for typed Tailwind variants, and effectively unavoidable if you touch shadcn/ui. Budget for tailwind-merge next to it, and accept that the published package has not moved since November 2024.

API stability5/5The whole surface is cva, cx, and the VariantProps type, and cva(base, config) has kept its shape for years. Because 1.0 lives only on the canary tag, nothing has broken under existing users.
Docs4/5cva.style is short, well organized, and shows every option with runnable examples plus framework recipes. The GitHub README is nothing but a link to it, and there is no generated API reference, so type-level questions send you to the .d.ts file.
Maintenance2/5Latest published release is 0.7.1 from November 2024, more than a year and a half ago, while 1.0 has stayed in canary. The tracker is nearly empty (3 open issues and PRs at review time) and commits still land, but a package with this much usage and no releases is a real risk.
Ecosystem4/5About 61M weekly downloads, carried largely by shadcn/ui component files, and 6.8k stars. There is no plugin scene because there is nothing to plug into, and the pieces you end up needing (tailwind-merge, the IntelliSense regex config) live outside the project.

Use it if

  • You maintain Tailwind design-system components and want size, intent, and state variants declared once instead of scattered through template ternaries
  • You use shadcn/ui or copied its component files: they already import cva, so removing it means rewriting every component by hand
  • You want the prop type generated from the class config via VariantProps<typeof buttonVariants> rather than maintaining a parallel union type
  • You need variant logic that works outside React too, because the output is just a string usable in Vue, Svelte, Astro, or a server-rendered template
Skip it if

Setup reality

npm install class-variance-authority pulls exactly one dependency (clsx) and ships its own types, so the install itself is a non-event. Everything annoying comes after. Tailwind IntelliSense will not complete or lint class names inside cva() or cx() until you add tailwindCSS.experimental.classRegex entries in your editor settings, and every teammate has to do it. Tailwind's content globs must cover the files where variants are declared, otherwise classes get purged from the build and only in production. Most teams then install tailwind-merge and wrap cx plus twMerge into a local cn() helper, because cva alone will happily hand you 'p-4 p-8'. There is no Babel plugin, no config file, and no runtime cost beyond string joining.

Patterns

Define a variant functiondefine-variants

import { cva } from 'class-variance-authority';

const button = cva('inline-flex items-center rounded font-medium', {
  variants: {
    intent: {
      primary: 'bg-blue-600 text-white hover:bg-blue-700',
      ghost: 'bg-transparent text-blue-600 hover:bg-blue-50',
    },
    size: {
      sm: 'h-8 px-3 text-sm',
      md: 'h-10 px-4 text-base',
    },
  },
});

button({ intent: 'primary', size: 'sm' });
// 'inline-flex items-center rounded font-medium bg-blue-600 text-white hover:bg-blue-700 h-8 px-3 text-sm'

The first argument is the base classes and may be omitted. Order in the output follows base, then variants in declaration order, which matters only if two variants set the same Tailwind property.

Set defaults so callers can pass nothingdefault-variants

const button = cva('rounded', {
  variants: {
    intent: { primary: 'bg-blue-600', ghost: 'bg-transparent' },
    size: { sm: 'h-8 px-3', md: 'h-10 px-4' },
  },
  defaultVariants: { intent: 'primary', size: 'md' },
});

button(); // 'rounded bg-blue-600 h-10 px-4'

defaultVariants apply when the key is missing or undefined. Passing an explicit undefined still falls back to the default; only null skips the variant entirely.

Add classes only for a combinationcompound-variants

const button = cva('rounded', {
  variants: {
    intent: { primary: 'bg-blue-600', danger: 'bg-red-600' },
    size: { sm: 'h-8 px-3', md: 'h-10 px-4' },
  },
  compoundVariants: [
    { intent: 'danger', size: 'sm', class: 'font-bold uppercase tracking-wide' },
  ],
  defaultVariants: { intent: 'primary', size: 'md' },
});

A compound entry fires only when every listed variant matches. Its class is appended after the regular variant classes, so it wins ties in source order but still loses to a later rule in your CSS.

Match several values in one compound rulecompound-variant-multi-match

const badge = cva('inline-block rounded px-2', {
  variants: {
    tone: { info: 'bg-sky-100', warn: 'bg-amber-100', error: 'bg-red-100' },
    outline: { true: 'border', false: '' },
  },
  compoundVariants: [
    { tone: ['warn', 'error'], outline: true, class: 'border-current' },
  ],
});

Arrays inside compoundVariants mean any-of. You cannot express not-equal or ranges; for that, compute the variant value before calling the function.

Use a boolean variantboolean-variant

const input = cva('block w-full rounded border px-3', {
  variants: {
    invalid: {
      true: 'border-red-500 focus:ring-red-500',
      false: 'border-gray-300 focus:ring-blue-500',
    },
  },
  defaultVariants: { invalid: false },
});

input({ invalid: true });

Keys are the strings 'true' and 'false' in the config, but the generated prop type is a real boolean. Declare the 'false' branch too, or the default state gets no classes at all.

Derive prop types from the configtyped-props

import { cva, type VariantProps } from 'class-variance-authority';

const button = cva('rounded', {
  variants: {
    intent: { primary: 'bg-blue-600', ghost: 'bg-transparent' },
    size: { sm: 'h-8', md: 'h-10' },
  },
});

type ButtonVariants = VariantProps<typeof button>;
// { intent?: 'primary' | 'ghost' | null; size?: 'sm' | 'md' | null }

VariantProps strips class and className from the inferred argument, so you can spread it into your own props type without a duplicate class prop.

Wire it into a React componentreact-component

import { cva, type VariantProps } from 'class-variance-authority';
import type { ButtonHTMLAttributes } from 'react';

const buttonVariants = cva('inline-flex items-center rounded', {
  variants: {
    intent: { primary: 'bg-blue-600 text-white', ghost: 'bg-transparent' },
    size: { sm: 'h-8 px-3', md: 'h-10 px-4' },
  },
  defaultVariants: { intent: 'primary', size: 'md' },
});

type Props = ButtonHTMLAttributes<HTMLButtonElement> &
  VariantProps<typeof buttonVariants>;

export function Button({ intent, size, className, ...rest }: Props) {
  return (
    <button className={buttonVariants({ intent, size, className })} {...rest} />
  );
}

Passing className through the variant call appends it last. Export buttonVariants separately so other components can reuse the same classes on an anchor or a Link.

Resolve Tailwind conflicts with a cn helpermerge-conflicting-classes

import { cx } from 'class-variance-authority';
import { twMerge } from 'tailwind-merge';

export function cn(...inputs: Parameters<typeof cx>) {
  return twMerge(cx(inputs));
}

// then, in the component
className={cn(buttonVariants({ intent, size }), className)}

This is the single most important thing cva does not do. Without twMerge, a caller passing 'p-8' to a component whose base is 'p-4' gets both classes and unpredictable spacing.

Turn a variant off with nullunset-variant

const button = cva('rounded', {
  variants: { size: { sm: 'h-8 px-3', md: 'h-10 px-4' } },
  defaultVariants: { size: 'md' },
});

button({ size: null }); // 'rounded'  (no size classes at all)

null is the only way to opt out of a defaultVariant. undefined falls back to the default, which surprises people passing an optional prop straight through.

Join conditional classes with cxcx-classnames

import { cx } from 'class-variance-authority';

cx('base', isActive && 'bg-blue-50', ['px-2', { 'sr-only': hidden }]);

cx is a re-export of clsx, not a separate implementation, so anything clsx accepts works here and there is no extra bytes cost if you already ship cva.

Restore Tailwind autocomplete inside cvaeditor-intellisense

// .vscode/settings.json
{
  "tailwindCSS.experimental.classRegex": [
    ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
    ["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
  ]
}

Without this, class names inside cva() get no completion, no hover preview, and no lint warnings. It is per-editor config, so it needs to be committed to the repo to help the whole team.

Alternatives

PackageRegistryPick it when
tailwind-variantsnpmYou need slots for multi-part components, variant composition, and class conflict resolution in one package instead of assembling them
tailwind-mergenpmYou only need the conflicting-class resolution part and are happy expressing variants with a plain object lookup
clsxnpmYour components toggle a handful of classes and never grow into a variant matrix