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.

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:
- Readability - Good luck parsing that string at a glance
- Class conflicts - What if both conditions add
px-4andpx-6? Tailwind doesn't resolve conflicts automatically - Type safety - No autocomplete, no error checking
- 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:
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 -
sizeonly accepts"sm" | "md" | "lg" - Default values - No size prop? You get
mdautomatically - Clean separation - Variants are declared once, used everywhere
- Type inference -
VariantPropsextracts 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:
- Defines variants with cva
- Merges variant classes with any custom
classNameusing cn() - 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:
{
"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:
- Install clsx and tailwind-merge
- Install class-variance-authority
- Create the cn() utility function
- Configure VS Code for IntelliSense
- 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.
Got questions about component styling or Tailwind patterns? Reach out at support@vibestacks.io.
Read more

Why Your Next.js App Feels Broken (And How Tests Save Your Refactor)
A practical look at Next.js caching strategies and why high level tests are your safety net when paying down tech debt

What Is Docker and Why Does Vibestacks Use It?
Docker eliminates 'works on my machine' problems. Here's what it actually does and why we include it in every Vibestacks project.

Why pnpm Is Better Than npm and Yarn
pnpm is faster, uses less disk space, and prevents dependency bugs. Here's why Vibestacks uses it and why you should too.