Incentive of using CVA

My Personal Take for Why ?

Whatever front-end stack you’re working on, a common pattern that you’ll come across is “Component“, and often times, for each of the component, you will need more than one variants to use it across different scenarios.

For example, for a “Button“ component, you might need:

  • intent variants: for different colour combination (e.g. primary, secondary, tertiary )
  • size variants: for different padding and font sizes (e.g. xs, sm, md, lg, xl)
  • disabled variants: for when the button is disabled (e.g. true, false)
  • child_type variants: to differentiate button’s styles when its inner child is different (e.g. text_only, icon_only, text_with_icon)

**Without CVA **, you can achieve variants of the component through: hand-rolled map (manually storing the mapping from each variant to its corresponding styles/classname):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import React from "react";
const btn_base = "inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors
const btn_intent = {
	primary:    "bg-primary-100 text-black hover:bg-primary-900 hover:text-white hover:cursor-pointer ...",
    secondary:  "... ... ...",
	tertiary:   "... ... ..."
}
const btn_size = {
	xs: "px-2 py-1 text-sm ...",
    sm: "... ... ...",
    md: "... ... ...",
    lg: "... ... ...",
    xl: "... ... ...",
}
const btn_disabled    = { "true":"opacity-50 pointer-events-none", "false":""}
const btn_child_type  = { text_only:"...", icon_only:"...", text_with_icon:"..."}

// Declare the property interface

export interface Button_withoutCVA_Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
	intent?:     key of typeof btn_intent,
	size?:       key of typeof btn_size,
	disabled?:   key of typeof btn_disabled,
	child_type?: key of typeof btn_child_type,
    className?:  string
}

// Declare the component

export default Button_withoutCVA = ({
    intent, size, disabled, child_type, /*variants*/
    className, /*classname override*/
    ...rest
}:Button_withoutCVA_Props) => {
	return (
        <button
          /* you might want to use tailwind merge to resolve potential classname conflict
             but here for demonstration purpose we're skipping this step */
          className={
                `${btn_base}` + " " +
                /* map          property        fallback / default value */
                /*  ↓               ↓              ↓            ↓        */
                `${btn_intent[     intent      ? intent     : "primary"]  }` + " " + 
                `${btn_size[       size        ? size       : "md"]       }` + " " + 
                `${btn_disabled[   disabled    ? disabled   : "false"]    }` + " " + 
                `${btn_child_type[ child_type  ? child_type : "text_only"]}` + " " +

               /* override classname such as "bg-red-200"*/
	            `${className}`
 		  }
          {...rest}
        />
	);
}

With CVA (with cva(default, {...}) function), you can avoid several problems by design, below is an example of the above manual hand-rolled mapping Button component written with CVA:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; // clsx + cn (tailwind-merge)

export const buttonVariants = cva(
  // Base: classes every button shares (styling/classname that are always applied)
  "inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors",
  {
    // Basic Variants: each set of styles are conditionally applied when the corresponding variant is met
    variants: {
      intent: {
        primary:   "bg-primary-100 text-black hover:bg-primary-900 hover:text-white hover:cursor-pointer",
        secondary: "...",
        tertiary:  "...",
      },
      size: {
        xs: "px-2 py-1 text-sm",
        sm: "...", md: "...", lg: "...", xl: "...",
      },
      disabled: {
        true:  "opacity-50 pointer-events-none",
        false: "",
      },
      child_type: {
        text_only:      "",
        icon_only:      "p-0 aspect-square",
        text_with_icon: "",
      },
    },
    // Compound Variants: styles that only apply to a COMBINATION of variants
    compoundVariants: [
      { child_type: "icon_only", size: "xs", class: "size-6" },
      { child_type: "icon_only", size: "md", class: "size-9" },
      { intent: ["primary", "secondary"], disabled: true, class: "bg-gray-200 text-gray-500" },
    ],
    defaultVariants: {
      intent: "primary",
      size: "md",
      disabled: false,
      child_type: "text_only",
    },
  }
);

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {}

export function Button({ intent, size, disabled, child_type, className, ...rest }: ButtonProps) {
  return (
    <button
      disabled={disabled ?? undefined}
      className={cn(buttonVariants({ intent, size, disabled, child_type }), className)}
      {...rest}
    />
  );
}

Below is a quick comparison table between the two examples (we’ll get back to the details in the next section):

ConcernHand-rolled maps ❌CVA ✅
Shared base classesRepeated or forgottenFirst argument
DefaultsTernary per prop, inlinedefaultVariants
Variant combinationsConditionals that keep growingcompoundVariants
Prop typesWritten by hand, drift from the maps
(using key of type of variant_map)
Built by VariantProps
Boolean variantsTyped as string keys ("true" and "false")Real boolean
Reuse on <a>/<Link>Copy and pasteCall buttonVariants()

Another good insentive for people to use CVA is that, the well renowned and popular ShadCN/UI component library is using CVA to manage its variants:

2026-10-09T105203

Source:

CVA Official Documentation

cva is a tiny library (≈10KB) for building type-safe, variant-driven class names with Tailwind CSS or any other styling approach.

Creating variants with the “traditional” CSS approach can become an arduous task: manually matching classes to props, and manually adding types.cva takes away those pain points, so you can focus on building your UI.

CSS-in-TS isn’t for everyone, though. You may need full control over your stylesheet output, use a framework such as Tailwind CSS, or prefer writing your own CSS.

– Source: https://cva.style/


Why is CVA Better (Breakdown)

Type Safety (Prop Type)

  • rather than the key of the manually created mapping
```diff
export interface Button_withoutCVA_Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
-	 intent?:     key of typeof btn_intent,
-	 size?:       key of typeof btn_size,
-	 disabled?:   key of typeof btn_disabled,
-	 child_type?: key of typeof btn_child_type,
-    className?:  string
}
```
  • Types comes from the config in the CVA function:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    
    export const buttonVariants = cva(
      "...",
      {
        variants: {
    +      intent: {
    +       primary:   "...", secondary: "...", tertiary:  "...",
    +      },
    +      size: {
    +        xs: "...", sm: "...", md: "...", lg: "...", xl: "...",
    +      },
    +      disabled: {
    +        true:  "...", false: "...",
    +      },
    +      child_type: {
    +        text_only: "...", icon_only: "...", text_with_icon: "...",
    +      },
        },
        compoundVariants: [ ... ],
        defaultVariants:  { ... },
      }
    );
    
  • Type can also be shared across different components (e.g. <a>, <Link>, <Button> may have the same variants)

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    
    export const sharedVariants = cva("...", {})
    
    export interface aProps
    +  extends VariantProps<typeof sharedVariants>;
    
    export interface linkProps
    +  extends VariantProps<typeof sharedVariants>;
    
    export interface ButtonProps
    +  extends VariantProps<typeof sharedVariants>;
    

Concise Default Values

  • rather than using variant_name ? variant_name : variant_default ternary operation to provide the default when the variant value is not provided (null or none):

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    
    export default Button_withoutCVA = ({ ... }:Button_withoutCVA_Props) => {
    	return (
            <button
              /* you might want to use tailwind merge to resolve potential classname conflict
                 but here for demonstration purpose we're skipping this step */
              className={
    -                `${btn_intent[     intent      ? intent     : "primary"]  }` + " " +
    -                `${btn_size[       size        ? size       : "md"]       }` + " " +
    -                `${btn_disabled[   disabled    ? disabled   : "false"]    }` + " " +
    -                `${btn_child_type[ child_type  ? child_type : "text_only"]}` + " " +
    	            `${className}`
     		  }
              {...rest}
            />
    	);
    }
    
  • default lives in the same place as the variants:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    
      export const buttonVariants = cva(
        "default_classnames ..."
        {
          variants:          { ... },
          compoundVariants:  [ ... ],
    +    defaultVariants: {
    +      intent: "primary",
    +      size: "md",
    +      disabled: false,
    +      child_type: "text_only",
          },
        }
      );
    

Combination of Variants (the biggest win)

  • instead of having to write your own logic (if-else inside component’s render function) to manually add certain classname when certain condition is met (e.g. if(btn_intent=="primary" && btn_disabled=="true"){className+="..."})

  • you get to use Compound Variants feature in CVA:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    
    export const buttonVariants = cva(
      "base_classes ...",
      {
        variants: { ... },
        compoundVariants: [
    +      { child_type: "icon_only", size: "xs", class: "size-6" },
    +      { child_type: "icon_only", size: "md", class: "size-9" },
    +      { intent: ["primary", "secondary"], disabled: true, class: "bg-gray-200 text-gray-500" },
        ],
        defaultVariants: { ... },
      }
    );
    

Shared Base Classes

  • Instead of having to repeat all identical base styling across all variants

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    
    const btn_intent = {
    +                   /* ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ Shared Classes ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓*/
    +       primary:   "inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors ... (primary---specific-classes)",
    +       secondary: "inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors ... (secondary-specific-classes)",
    +       tertiary:  "inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors ... (tertiary--specific-classes)",
    }
    export default Button_withoutCVA = ({ ... }:Button_withoutCVA_Props) => {
    	return (
            <button
              className={
                    `${btn_intent[     intent      ? intent     : "primary"]  }` + " " +
                    `${btn_size[       size        ? size       : "md"]       }` + " " +
                    `${btn_disabled[   disabled    ? disabled   : "false"]    }` + " " +
                    `${btn_child_type[ child_type  ? child_type : "text_only"]}` + " " +
    	            `${className}`
     		  }
              {...rest}
            />
    	);
    }
    

    OR declare additional base_class variable (but you may easily forgot to do so….)

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    
    + const btn_base = "inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors
    export default Button_withoutCVA = ({ ... }:Button_withoutCVA_Props) => {
    	return (
            <button
              className={
    +               `${btn_base}` + " " +
                    `${btn_size[       size        ? size       : "md"]       }` + " " + `${btn_disabled[   disabled    ? disabled   : "false"]    }` + " " + `${btn_child_type[ child_type  ? child_type : "text_only"]}` + " " + `${className}`
     		  }
              {...rest}
            />
    	);
    }
    
  • if you use CVA the first parameter of the cva() function if the shared styles/classnames:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    
    export const buttonVariants = cva(
    +  // Base: classes every button shares (styling/classname that are always applied)
    +  "inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors",
      {
        variants:         { ... },
        compoundVariants: [ ... ],
        defaultVariants:  { ... },
      }
    );
    
    export interface ButtonProps
      extends React.ButtonHTMLAttributes<HTMLButtonElement>,
        VariantProps<typeof buttonVariants> {}
    
    export function Button({ intent, size, disabled, child_type, className, ...rest }: ButtonProps) {
      return (
        <button
          disabled={disabled ?? undefined}
          className={
          	cn(
    +      		buttonVariants(
    +      			{ intent, size, disabled, child_type }
    +      		),
          		className
          	)
          }
          {...rest}
        />
      );
    }
    

Tailwind Classname Conflict

As you might have noticed in the above example, we have imported an additional function cn(), it is a utility function meant for efficiently merge tailwind CSS classes in JS without causing style conflicts.

This is because sometimes you may come up with classname such as <div class='px-2 py-1 bg-red-500 hover:bg-red-700 p-3 bg-[#B91C1C]'> ... , since all of those tokens are utility tokens of the same level, which one will take precedence will only be dependent on their position of declaration in the CSS file (which is controlled by the bundler at the runtime, hence we have no control over it), hence for the above example, we aren’t quite sure what will the final background colour applied to the <div>…

What cn() function does, is to solve the conflicting classes (e.g. bg-red-500, bg-[#B91C1C]) by solves this by understanding Tailwind’s utility structure. It intelligently removes redundant or conflicting utilities so your components can be safely customized externally: cn('px-2 py-1 bg-red-500 hover:bg-red-700', 'p-3 bg-[#B91C1C]') ➡️ 'hover:bg-red-700 p-3 bg-[#B91C1C]'

Actually, you may use either one of the approach to resolve this:

I’m not intending to figure out the differences between the two JavaScript tailwind merge package at the moment, but maybe you should do so if you’re interested….

Competitor: Tailwind-Variants

If you’re using Tailwind, it might also worth your attention to take a look at this project: Tailwind Variants; It provides additional features including: slots, build-in merge, compound slots, , and design system-oriented API.

2026-10-09T095628

(source: https://www.tailwind-variants.org/docs/comparison)

But there are also two main drawbacks of tailwind-variants that you should consider:

  1. it is of larger bundle size (≈300KB vs ≈20KB)
  2. it has less attention and downloads comparing to CVA

2026-10-09T094924

(source: https://npmx.dev/compare?packages=tailwind-variants,class-variance-authority)


Reference