← Back to Blogs

4 min read

title: Scaling Tailwind CSS: Building a Type-Safe Design System with CVA and Tailwind Merge date: 2026-07-11T07:14:17.233Z description: Blog about Scaling Tailwind CSS: Building a Type-Safe Design System with CVA and Tailwind Merge

Hey everyone, I’m Prashuk.

If you’ve been using Tailwind CSS for a while, you know the honeymoon phase. It’s fast, you don’t have to leave your HTML, and everything just works. But honestly? Once a project starts growing—especially when you're building out a full dashboard or a complex SaaS product—the "class soup" starts getting real.

I noticed this a few months ago while working on a large-scale project. We had buttons everywhere. Primary buttons, ghost buttons, buttons with icons, loading states... the logic inside my components was becoming a nightmare of ternary operators and messy template literals.

Here is how I fixed it and built a design system that actually scales without losing my mind.

The problem with "Standard" Tailwind

In one of my projects, I had a Button component that looked something like this:

<button className={`px-4 py-2 ${isPrimary ? 'bg-blue-500' : 'bg-gray-200'} ${size === 'lg' ? 'text-lg' : 'text-sm'}`}>

It looks okay at first, but what happens when you have 5 variants, 3 sizes, and a "disabled" state? It becomes unreadable. Plus, if you try to override a padding class from the outside, Tailwind’s cascade often ignores it because of how CSS classes are ordered in the final bundle.

I ran into this issue when a specific "Delete" button needed a slightly different margin, but no matter what I passed in the className prop, the default component styles kept winning.

Why CVA (Class Variance Authority) is a game changer

What worked for me was switching to CVA. Think of CVA as a way to define a "schema" for your UI components. Instead of messy logic inside the return statement, you define your variants in a structured object.

It feels much more like "Config-driven development." You define what a "primary" button looks like once, and then you just call it. It also plays incredibly well with TypeScript, which is a huge win for me because I hate guessing which props a component accepts.

The "Tailwind Merge" Secret Sauce

CVA is great for structure, but it doesn't solve the "class conflict" problem I mentioned earlier. That’s where tailwind-merge comes in.

I always create a utility function called cn (short for class name). It combines clsx (for conditional classes) and tailwind-merge.

What this does is simple: if I have a default class of p-4 and I pass p-2 as a prop, tailwind-merge makes sure p-2 actually overrides the original. It sounds small, but it saves so much frustration during UI polish.

Putting it into practice

Here’s a simplified version of the Button component I use in almost all my Next.js projects now. It’s clean, type-safe, and very easy to extend.

import { cva, type VariantProps } from 'class-variance-authority';
import { twMerge } from 'tailwind-merge';
import { clsx, type ClassValue } from 'clsx';

// 1. The Utility Function
function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

// 2. Defining the Variants
const buttonVariants = cva(
  "inline-flex items-center justify-center rounded-md font-medium transition-colors focus:outline-none",
  {
    variants: {
      variant: {
        primary: "bg-blue-600 text-white hover:bg-blue-700",
        outline: "border border-gray-300 bg-transparent hover:bg-gray-100",
        ghost: "bg-transparent hover:bg-gray-100 text-gray-700",
      },
      size: {
        sm: "h-8 px-3 text-xs",
        md: "h-10 px-4 py-2 text-sm",
        lg: "h-12 px-8 text-base",
      },
    },
    defaultVariants: {
      variant: "primary",
      size: "md",
    },
  }
);

// 3. The Component
interface ButtonProps 
  extends React.ButtonHTMLAttributes<HTMLButtonElement>, 
          VariantProps<typeof buttonVariants> {}

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

Why I prefer this approach

  • Clean Code: My component logic is now just one line. No more if/else hell.
  • Performance: There’s almost no overhead. It’s just strings being joined.
  • Scalability: If my designer tells me all "Primary" buttons need to be purple instead of blue, I change it in exactly one place.
  • Predictable UI: Because of twMerge, I know that if I pass a custom class from a parent component, it will actually be applied.

Final thoughts

I used to think setting up CVA was "over-engineering" for small projects. I was wrong. Even for a simple landing page, having a structured way to handle UI states makes the development process so much smoother.

If you're still fighting with long strings of Tailwind classes, give this a shot. It makes the jump from "just a project" to a "real design system" much easier.

Let me know if you’ve tried this or if you have a different way of handling Tailwind at scale! Keep coding.