[Styling, ReactJS]

23 Sep 2026

-

7 min read time

CVA vs Tailwind Variants: Choosing a React Styling API

Compare CVA and Tailwind Variants for React components, including button recipes, multi-part fields, class overrides and a practical migration decision.

Kalle Bertell

By Kalle Bertell

Violet hands assemble white interface components with green controls and a row of purple, green and white variant tiles.

A button begins with a few Tailwind classes. Then it needs two sizes, three tones, a loading state and an icon-only form. Another developer adds a className override. Six months later, nobody is sure which combinations are supported.

CVA and Tailwind Variants both help give those choices names. The more useful comparison is how each fits the components your team owns: simple elements, multi-part controls, shared recipes and exceptions requested by product teams.

For a library dominated by single-element components, CVA is a reasonable starting point. Tailwind Variants becomes attractive when several parts of a component need coordinated styles. Existing conventions, override policy and maintenance cost should carry more weight than the novelty of either API.

Be precise about the CVA version

The examples here use the stable class-variance-authority package at version 0.7.1 and Tailwind Variants 3.3.1. The newer package named cva is on the 1.0 beta release line at the time of writing, including 1.0.0-beta.12. Do not mix installation commands and examples from the two package lines.

The current CVA documentation covers the newer direction. For an existing stable installation, check the class-variance-authority package and the version in your lockfile before adopting a snippet. A package rename or beta release is a separate migration decision from choosing an API for your design system.

The comparison below uses Tailwind CSS 4 conventions and tailwind-merge 3 for conflict resolution. Teams on another Tailwind major should verify the matching merge-library version instead of copying the dependency set unchanged.

Compare the decisions your team will actually make

Decision

Stable CVA

Tailwind Variants

One element with named variants

A focused recipe function

A recipe function with similar concepts

A component with several styled parts

Separate recipes or your own composition

Named slots in one recipe

Conflicting Tailwind utilities

Add a merge policy separately

Default build supports merging

Consuming variant types

Extract types from the recipe

Extract types from the recipe

Custom component behavior

Implement in the React component

Implement in the React component

Existing codebase

Usually best to preserve a working convention

Adopt where its extra structure is useful

Neither library decides your accessibility semantics, token names or supported component states. A typed tone prop can still expose a poor API. Decide whether a button should allow a destructive tone in a loading state before encoding every possible combination.

Start the comparison with three representative components: a button, a field and a card. They expose different pressures without requiring a speculative rewrite of the entire library.

Build the same button with both

Here is a stable CVA recipe with two independent choices:

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

export const buttonCva = cva(
  'inline-flex items-center justify-center rounded-md font-medium',
  {
    variants: {
      tone: {
        primary: 'bg-violet-700 text-white',
        neutral: 'bg-slate-100 text-slate-900',
      },
      size: {
        sm: 'h-8 px-3 text-sm',
        md: 'h-10 px-4 text-base',
      },
    },
    defaultVariants: { tone: 'primary', size: 'md' },
  },
);

The same choices in Tailwind Variants:

  import { tv } from 'tailwind-variants';

export const buttonTv = tv({
  base: 'inline-flex items-center justify-center rounded-md font-medium',
  variants: {
    tone: {
      primary: 'bg-violet-700 text-white',
      neutral: 'bg-slate-100 text-slate-900',
    },
    size: {
      sm: 'h-8 px-3 text-sm',
      md: 'h-10 px-4 text-base',
    },
  },
  defaultVariants: { tone: 'primary', size: 'md' },
});

Call either function with tone and size to obtain a class string. For this component, the choice is mostly about conventions and surrounding tooling. Tailwind Variants describes its recipe model in the introduction .

Keep application language in the API. A product team can understand tone="primary" more easily than a prop called violet. If the brand color changes, the semantic prop can remain stable.

The sample is deliberately limited to styling. A production button still needs native button props, an intentional type, focus treatment, disabled behavior and accessible handling of icons and loading. Add those in the component and test them as behavior. A class-generation library cannot make that decision for you.

Decide how overrides should work

Suppose a consumer adds px-8 to a medium button whose recipe includes px-4. Concatenating both strings does not establish a reliable override contract based on their order in the HTML attribute.

With stable CVA, you can make conflict resolution explicit at the component boundary:

  import { twMerge } from 'tailwind-merge';
import { buttonCva } from './button-cva.mjs';

export function buttonClasses(options, className) {
  return twMerge(buttonCva(options), className);
}

Tailwind Variants' default build supports Tailwind class merging; its lite build omits that behavior. Its configuration guide covers per-recipe configuration and shared defaults. The tailwind-merge project documents compatibility and the scope of its conflict handling.

Choose a policy before exposing className everywhere. One team may permit layout overrides but expect colors and spacing inside the component to follow tokens. Another may intentionally offer recipes as a low-level building block. Both approaches can work if consumers know which guarantees they are getting.

Test project-specific utilities. A merge library does not automatically understand every custom class or plugin your team has invented. Include examples of your actual spacing, typography and color utilities in the evaluation.

Do not use merging to hide an incoherent component API. If half the consumers override a button's height, the library may need another supported size or a different component.

Use a field to examine coordinated styling

A field has more than an input. It can include a label, help text, an error message and an outer wrapper. An invalid state may affect several of them together.

Tailwind Variants slots let one recipe return functions for those parts:

  import { tv } from 'tailwind-variants';

export const field = tv({
  slots: {
    root: 'grid gap-2',
    label: 'text-sm font-medium text-slate-900',
    input: 'rounded-md border border-slate-300 px-3 py-2',
    message: 'text-sm text-slate-600',
  },
  variants: {
    invalid: {
      true: {
        input: 'border-red-700',
        message: 'text-red-700',
      },
      false: {},
    },
  },
  defaultVariants: { invalid: false },
});

const styles = field({ invalid: true });
console.log(styles.input());
console.log(styles.message());

The slots documentation explains this multi-part model. With CVA, you can instead keep separate recipes for the input and message, then have the React component pass the same invalid state to each. That is straightforward for two parts; the coordination becomes more noticeable as the component grows.

Keep the label association, aria-invalid and error-message relationship in the rendered component. Red borders alone do not communicate the full state to every user. A useful evaluation includes keyboard interaction and assistive-technology semantics alongside screenshots.

The deciding question is whether grouping the styles reduces mistakes in your implementation. Count the places an engineer must edit to add a new state. If the slots recipe is easier to review and its consumers remain understandable, it has earned its place.

Use a card to test the limits of variants

Cards are where a tidy variant system can become a substitute for composition. One design has a header and footer, another an image, and a third a form with its own actions. Encoding every structural difference as a boolean leads to combinations that designers never intended.

Use a representative card with a root, heading, body and footer. Compare how each library handles a density choice that changes padding across those parts. Then ask a different question: should the image and form versions share the same component at all?

With CVA, independent recipes can make separately reusable parts convenient. With slots, the relationship between the parts is visible in one definition. Neither approach requires placing every possible card layout behind a single component API.

Prefer composition for meaningful structural differences. Our guide to React component composition discusses that boundary. A styling recipe is easier to maintain when it describes a component that already has a clear responsibility.

Keep types and generated CSS in the evaluation

Extracting variant types prevents callers from casually inventing unsupported values. It does not prove that the expected CSS was generated or that a component looks correct at every breakpoint.

Build a small fixture in the consuming application, using its real Tailwind setup. Render each supported button combination, the field's valid and invalid states, and the card's density choices. Check the production build, not only a development playground.

Keep complete utility strings in source where the compiler can discover them. Be particularly careful when a shared component package sits outside the consuming application's normal source scanning. Tailwind's source detection documentation explains how to include sources explicitly.

For TypeScript consumers, include one valid usage and one expected type error in the fixture. This catches changes where a wrapper widens a carefully inferred prop into an arbitrary string. If the component is published as a package, test its exported types from a separate consumer rather than only inside its own source tree.

Migrate only when the benefit is visible

A codebase already using CVA consistently does not need a rewrite because another library supports slots. Equally, a team maintaining many multi-part recipes should not dismiss slots simply to keep its dependency list shorter.

Trial the alternative on a component that is already due for work. Record the amount of coordination code, the clarity of consumer usage and the failure cases found in review. Include the cost of documenting the convention and teaching it to other contributors.

Avoid claiming a bundle-size win without measuring your actual build. Imports, merging behavior, rendering location and package versions all influence what ships to the browser. Compare equivalent behavior and report the measurement conditions.

For a new system, start with CVA when the requirements stay small and your team wants to assemble its own conventions. Choose Tailwind Variants when grouped parts and shared recipe configuration simplify the components you actually have. Revisit the choice if those needs change.

The larger design-system decisions remain token ownership, accessibility, component boundaries and release discipline. Makers' Den's React development work and our guide to design systems with Tailwind CSS cover the surrounding engineering that makes either library useful over time.

Kalle Bertell

By Kalle Bertell

More from our Blog

Keep reading