Taming Tailwind Chaos & Conflicts

A practical comparison of Class Variance Authority (CVA), Tailwind Variants, and tailwind-merge — what each one does, and which to reach for in a Tailwind component library.

Taming Tailwind Chaos & Conflicts

If you've built a component library with Tailwind CSS, you've probably hit the same wall: a Button component that starts clean and, six months later, is a jungle of ternaries and template strings trying to handle variant, size, and state combinations. Three libraries have emerged to solve this specific pain point:

They overlap in purpose but solve slightly different problems, and understanding where each one fits will save you from reaching for the wrong tool.

The Problem at Hand

A typical component without any CVA library might look like this:

function Button({ variant, size, disabled, className }: ButtonProps) {
    return (
        <button
            className={`
                rounded font-medium transition-colors
                ${variant === 'primary' ? 'bg-blue-600 text-white hover:bg-blue-700' : ''}
                ${variant === 'secondary' ? 'bg-gray-200 text-gray-900 hover:bg-gray-300' : ''}
                ${size === 'sm' ? 'px-2 py-1 text-sm' : ''}
                ${size === 'lg' ? 'px-6 py-3 text-lg' : ''}
                ${disabled ? 'opacity-50 cursor-not-allowed' : ''}
                ${className || ''}
            `}
        />
    )
}

It works, but it's unreadable, hard to test, and every new variant adds another ternary. This is exactly the gap these three libraries fill — just at different layers.

Class Variance Authority (CVA)

CVA gives a declarative API for defining variants and their combinations. Define a base, a set of variants, defaultVariants and optional compoundVariants.

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

const button = cva('rounded font-medium transition-colors', {
    variants: {
        variant: {
            primary: 'bg-blue-600 text-white hover:bg-blue-700',
            secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300',
        },
        size: {
            sm: 'px-2 py-1 text-sm',
            lg: 'px-6 py-3 text-lg',
        },
    },

    compoundVariants: [
        { variant: 'primary', size: 'lg', class: 'shadow-md' },
    ],

    defaultVariants: {
        variant: 'primary',
        size: 'sm',
    },
})

type ButtonProps = VariantProps<typeof button>

function Button({ variant, size, className }: ButtonProps & { className?: string }) {
    return <button className={button({ variant, size, className })} />
}

What makes CVA useful in practice:

  • It's framework-agnostic. The button() function just returns a string. It works the same no matter the framework used, be it React, Vue, Angular or a plain HTML template. It's a real advantage when you're maintaining a design system across frameworks.

  • VariantProps gives you free TypeScript types. Your component's prop types are derived directly from the variant definition, so they can never drift out of sync.

  • It's intentionally unopinionated about the rest of your component. CVA only builds class strings — it doesn't touch merging or conflict resolution, which is where tailwind-merge comes in.

CVA's limitation is that it's single-component-scoped by design. If you need slot-based variants (styling multiple internal parts of one component — think a Card with root, header, and body that all need their own variant logic), you end up composing multiple cva() calls manually.

Tailwind Variants

Tailwind Variants (tv) is best thought of as "CVA + slots + tailwind-merge, built in." It was created specifically to address CVA's gaps for compound components.

import { tv } from 'tailwind-variants'

const card = tv({
    slots: {
        root: 'rounded-lg border',
        header: 'border-b px-4 py-2 font-semibold',
        body: 'p-4',
    },
    variants: {
        color: {
            primary: {
                root: 'border-blue-200',
                header: 'bg-blue-50 text-blue-900',
            },
            danger: {
                root: 'border-red-200',
                header: 'bg-red-50 text-red-900',
            },
        },
    },
});

const { root, header, body } = card({ color: 'primary' });

Each slot gets its own class string, all derived from a single variant definition — no manually wiring together three separate cva() calls and passing color props down to each.

The other meaningful difference: Tailwind Variants merges conflicting classes automatically (it bundles its own conflict resolution, conceptually similar to tailwind-merge), so tv({ base: 'px-2', })({ className: 'px-4' }) resolves sensibly instead of emitting both classes and letting Tailwind's CSS cascade decide by source order, which is a common source of "why isn't my override working" bugs.

The trade-off is that Tailwind Variants is more opinionated and slightly heavier, and being younger than CVA, its ecosystem examples and community Q&A are thinner. For a simple single-element component, it's arguably more machinery than you need. That's CVA's sweet spot.

tailwind-merge

"tailwind-merge" solves a completely different problem: class conflict resolution, not variant authoring. Tailwind classes don't override each other based on specificity like normal CSS — they override based on source order in the generated stylesheet, which is often not the order they appear in your markup. Concatenating px-2 and px-4 doesn't give you px-4; it gives you whichever one Tailwind happened to generate last, which is fragile and easy to get wrong when className props come from a consumer.

import { twMerge } from 'tailwind-merge'

twMerge('px-2 py-1 bg-red-500', 'px-4 bg-blue-500')
// → 'py-1 px-4 bg-blue-500'

"tailwind-merge" understands Tailwind's own class groups (padding, background color, etc.) and correctly resolves which class in a given group should "win," regardless of order. It's small, does one job, and is often paired with clsx for a common cn() utility:

import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'

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

This cn() pattern is everywhere in the shadcn/ui ecosystem, as well as in major UI libraries such as HeroUI, for a reason: it lets a component define sensible default classes while still letting a consumer override them via className, without the two fighting over CSS specificity.

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

export function ({ className, children }: { className?: string, children?: ReactNode }) {
    return <div className={cn('bg-slate-100', className)}></div>
}
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'

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

How they actually fit together

These aren't really competitors — CVA and Tailwind Variants operate at the "variant authoring" layer, while "tailwind-merge" operates at the "conflict resolution" layer underneath them. A common, pragmatic stack looks like:

Need

Reach for

Single component, few variants, framework-agnostic

CVA

Compound component with multiple styled slots

Tailwind Variants

Merging a consumer's className prop safely into any of the above

tailwind-merge (often via a cn() helper)

CVA output that also needs to merge overrides

CVA + tailwind-merge together

In fact, CVA's own docs recommend pairing it with tailwind-merge for exactly this reason — CVA doesn't dedupe conflicting classes on its own, so if a consumer passes className="px-8" into a button that already resolves to px-2 internally, you'll get both in the DOM unless something merges them.