Back to all articles
TechnicalFeature

Why We Use cn() and cva() for Component Styling

String concatenation for Tailwind classes is a mess. Here's how cn() and cva() make conditional styling clean, type-safe, and maintainable.

Why We Use cn() and cva() for Component Styling

If you've worked with Tailwind CSS in React, you've probably written something like this:

<button 
  className={`px-4 py-2 rounded-md ${isLoading ? 'bg-gray-400 cursor-not-allowed' : 'bg-blue-500 hover:bg-blue-600'} ${size === 'large' ? 'text-lg px-6 py-3' : 'text-sm'}`}
>
  Click me
</button>

It works. It's also unreadable, error-prone, and a nightmare to maintain.

In this post, I'll show you how we use cn() and cva() in Vibestacks to handle component styling - and why these two utilities are essential for any serious Tailwind project.

The Problem with String Concatenation

Let's count the issues with the code above:

  1. Readability - Good luck parsing that string at a glance
  2. Class conflicts - What if both conditions add px-4 and px-6? Tailwind doesn't resolve conflicts automatically
  3. Type safety - No autocomplete, no error checking
  4. Maintenance - Adding a new variant means editing a growing mess of ternaries

As components grow, this pattern becomes unsustainable. You end up with 200-character className strings that nobody wants to touch.

Enter cn(): Clean Conditional Classes

The cn() function combines two libraries:

  • clsx - Conditionally joins class names
  • tailwind-merge - Intelligently resolves Tailwind class conflicts

Here's how it works:

import { cn } from "@/lib/utils"

<button 
  className={cn(
    "px-4 py-2 rounded-md",
    isLoading && "bg-gray-400 cursor-not-allowed",
    !isLoading && "bg-blue-500 hover:bg-blue-600",
    size === "large" && "text-lg px-6 py-3"
  )}
>
  Click me
</button>

Much better. Each condition is on its own line, easy to read and modify.

The Magic of tailwind-merge

Here's what makes cn() special. Notice that we have px-4 in the base styles and px-6 in the large size variant. With regular string concatenation, you'd get:

<button class="px-4 py-2 rounded-md text-lg px-6 py-3">

Both px-4 and px-6 are in the class list. Which one wins? It depends on the order in Tailwind's generated CSS - unpredictable and buggy.

With tailwind-merge, the later class wins automatically:

<button class="py-2 rounded-md text-lg px-6 py-3">

px-4 is removed because px-6 overrides it. No conflicts, no surprises.

The cn() Implementation

Here's exactly how we implement it in Vibestacks:

lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

Four lines. That's it. But these four lines will save you hours of debugging class conflicts.

Enter cva(): Component Variants Done Right

cn() handles conditional classes. But what about components with multiple variants - like buttons with different sizes, colors, and states?

That's where class-variance-authority (cva) comes in.

The Old Way

Without cva, you'd write something like this:

function Button({ size, variant, children }) {
  return (
    <button
      className={cn(
        "rounded-md font-medium transition-colors",
        // Size variants
        size === "sm" && "px-2 py-1 text-sm",
        size === "md" && "px-4 py-2 text-base",
        size === "lg" && "px-6 py-3 text-lg",
        // Color variants
        variant === "primary" && "bg-blue-500 text-white hover:bg-blue-600",
        variant === "secondary" && "bg-gray-200 text-gray-900 hover:bg-gray-300",
        variant === "destructive" && "bg-red-500 text-white hover:bg-red-600",
      )}
    >
      {children}
    </button>
  )
}

This works, but it's not great:

  • No TypeScript autocomplete for valid variants
  • Easy to typo a variant name
  • Default values require extra logic
  • Compound variants (e.g., "large + destructive") get messy

The cva Way

Here's the same component with cva:

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

const buttonVariants = cva(
  // Base styles (always applied)
  "rounded-md font-medium transition-colors",
  {
    variants: {
      size: {
        sm: "px-2 py-1 text-sm",
        md: "px-4 py-2 text-base",
        lg: "px-6 py-3 text-lg",
      },
      variant: {
        primary: "bg-blue-500 text-white hover:bg-blue-600",
        secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300",
        destructive: "bg-red-500 text-white hover:bg-red-600",
      },
    },
    defaultVariants: {
      size: "md",
      variant: "primary",
    },
  }
)

// TypeScript knows exactly what props are valid
type ButtonProps = VariantProps<typeof buttonVariants> & {
  children: React.ReactNode
}

function Button({ size, variant, children }: ButtonProps) {
  return (
    <button className={buttonVariants({ size, variant })}>
      {children}
    </button>
  )
}

Now you get:

  • Full TypeScript autocomplete - size only accepts "sm" | "md" | "lg"
  • Default values - No size prop? You get md automatically
  • Clean separation - Variants are declared once, used everywhere
  • Type inference - VariantProps extracts the prop types for you

Compound Variants

cva also handles compound variants - styles that only apply when multiple conditions are true:

const buttonVariants = cva("rounded-md font-medium", {
  variants: {
    size: {
      sm: "px-2 py-1 text-sm",
      lg: "px-6 py-3 text-lg",
    },
    variant: {
      primary: "bg-blue-500 text-white",
      destructive: "bg-red-500 text-white",
    },
  },
  compoundVariants: [
    {
      // Large + destructive = extra padding and bold text
      size: "lg",
      variant: "destructive",
      className: "px-8 font-bold",
    },
  ],
})

Try doing that cleanly with string concatenation.

Using cn() and cva() Together

The real power comes from combining them. Here's a pattern we use constantly in Vibestacks:

const buttonVariants = cva(
  "rounded-md font-medium transition-colors",
  {
    variants: {
      size: { sm: "px-2 py-1", md: "px-4 py-2", lg: "px-6 py-3" },
      variant: { primary: "bg-blue-500", secondary: "bg-gray-200" },
    },
    defaultVariants: { size: "md", variant: "primary" },
  }
)

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

function Button({ size, variant, className, ...props }: ButtonProps) {
  return (
    <button 
      className={cn(buttonVariants({ size, variant }), className)} 
      {...props} 
    />
  )
}

This pattern:

  1. Defines variants with cva
  2. Merges variant classes with any custom className using cn()
  3. Passes through all standard button props

The consumer can now do:

// Uses defaults
<Button>Click me</Button>

// Custom variant
<Button size="lg" variant="destructive">Delete</Button>

// Override with custom classes (cn handles conflicts)
<Button className="mt-4 bg-purple-500">Custom</Button>

The bg-purple-500 will override the variant's background color thanks to tailwind-merge.

IDE Setup for cn() and cva()

One gotcha: Tailwind IntelliSense only works in className="" by default. To get autocomplete inside cn() and cva(), add this to your VS Code settings:

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

Now you'll get full autocomplete everywhere you write Tailwind classes.

Skip the Setup

Setting up cn(), cva(), and the proper TypeScript patterns takes time. You need to:

  1. Install clsx and tailwind-merge
  2. Install class-variance-authority
  3. Create the cn() utility function
  4. Configure VS Code for IntelliSense
  5. Set up the component patterns correctly

Or you can skip all of it.

Vibestacks ships with cn() and cva() pre-configured, along with 100+ professionally built components and 70+ blocks using these patterns. Every button, input, card, and dialog follows this architecture out of the box.

Check out Vibestacks →


Got questions about component styling or Tailwind patterns? Reach out at support@vibestacks.io.