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

Tailwind CSS v4: What Changed and Why It's Better
No more tailwind.config.ts. Tailwind v4 moves configuration to CSS, drops JavaScript, and ships 2x faster. Here's everything that changed.

Stop Trusting process.env: Type-Safe Env Variables in Next.js
process.env fails silently and leaks secrets. Here's how t3-env catches missing env vars at build time, not production.

Beyond Pageviews: Why We implemented Analytics into vibestacks PRO
Shipping is only step one. Here's why we pre-configured PostHog to track user intent, not just traffic and why it matters for your growth.