Tailwind CSS 52 🎨 Class Merging Strategies and Class Variance Authority (tailwind-merge, cva)
Utility-first CSS produces long class strings. In a simple component, that is a readability concern. In a reusable component, it becomes a correctness concern. When a component defines base classes and the consumer passes override classes, concatenating the strings produces conflicts that CSS resolves by source order, not by intent. The consumer’s px-6 might lose to the component’s px-4 if the component’s stylesheet is emitted later. The tailwind-merge library exists to resolve this class of problem, and the class-variance-authority library, commonly shortened to cva, exists to structure the variants that produce those class strings in the first place. Together they form the standard toolkit for building composable, overridable Tailwind components.
Key point: tailwind-merge resolves conflicts between utility classes. cva defines variants and their class combinations. They solve different problems and are usually used together: cva produces the class string, and tailwind-merge ensures the consumer’s overrides win.
Why class merging and variance exist
The conflict problem. Tailwind utilities target the same CSS properties. px-4 and px-6 both set horizontal padding. bg-blue-500 and bg-red-500 both set background color. When both classes are present on an element, CSS resolves the conflict by the order in which the declarations appear in the stylesheet, not the order in which they appear in the class attribute. Tailwind’s generated stylesheet has a deterministic order, but that order is not the order a developer would intuitively expect. The result is that overrides do not always work, and the behavior depends on which utilities happen to be emitted in which order.
The override problem. A reusable component should ship with sensible defaults and let the consumer override them. Without conflict resolution, the consumer must either accept the defaults or fight the stylesheet. The idiomatic pattern is to accept a className prop, merge it with the component’s base classes, and let the consumer’s classes win. Doing this correctly requires understanding which utilities conflict with which, which is what tailwind-merge encodes.
The variant problem. Many components have multiple visual variants. A button can be primary, secondary, or danger. It can be small, medium, or large. It can be enabled or disabled. Each combination of variants produces a different class string. Writing conditional logic to assemble these strings by hand is verbose and error-prone. cva provides a declarative API: define the base classes, define each variant and its options, and call the function with the selected variants. The function returns the correct class string.
The composition problem. Variants and merging interact. A component with variants might be rendered by a parent that also wants to override a specific utility. The parent’s override must win, even against the variant’s classes. The standard pattern is to run the variant function first, then merge the result with the consumer’s className using tailwind-merge. The order matters: the consumer’s classes are always last.
The type-safety problem. Variants are string unions. A button’s intent prop is 'primary' | 'secondary' | 'danger'. TypeScript can infer these unions from the cva configuration, so the component’s props are typed without manually declaring the unions. This catches typos at compile time instead of silently falling back to a default.
The consistency problem. Without a structured variant system, every developer writes their own conditional class logic. One component uses size === 'sm' ? 'px-2 py-1' : 'px-4 py-2', another uses a switch statement, a third uses an object map. The inconsistency makes the codebase harder to reason about. cva provides one pattern for all variant-based components.
a. tailwind-merge
tailwind-merge is a function that takes multiple class strings and returns a single string with conflicting utilities resolved. It keeps the last utility in each conflict group and discards the earlier ones. It knows the Tailwind utility taxonomy, including modifiers like hover:, focus:, and responsive prefixes like md:.
The basic conflict resolution
The core behavior is simple: later classes win. If two classes target the same CSS property, the second one is kept and the first is dropped.
import { twMerge } from 'tailwind-merge';
twMerge('px-4 py-2', 'px-6');
// Result: 'py-2 px-6'
// px-4 was removed because px-6 overrides it.
// py-2 was kept because it does not conflict.
twMerge('bg-red-500', 'bg-blue-500');
// Result: 'bg-blue-500'
// bg-red-500 was removed.
twMerge('text-sm', 'text-lg');
// Result: 'text-lg'
The function returns a string. It does not modify the input strings. The order of arguments determines the winner: later arguments override earlier ones.
Conflict groups
tailwind-merge understands which utilities conflict with which. px-4 conflicts with px-6 because both set horizontal padding. px-4 does not conflict with py-2 because they target different properties. p-4 conflicts with both px-4 and py-2 because it sets all padding, and the more specific utilities should win.
twMerge('p-4', 'px-6');
// Result: 'px-6'
// p-4 sets all padding, px-6 overrides horizontal.
// The vertical padding from p-4 is lost, but that is the correct
// behavior: the more specific utility wins entirely.
twMerge('px-4 py-2', 'p-6');
// Result: 'p-6'
// p-6 overrides both px-4 and py-2.
The exact conflict rules are encoded in tailwind-merge‘s configuration. The library ships with defaults for the standard Tailwind utility set and updates them as Tailwind evolves.
Modifier awareness
tailwind-merge treats modifiers as part of the conflict context. hover:px-4 and hover:px-6 conflict with each other but not with px-4. md:px-4 and lg:px-6 do not conflict because they apply at different breakpoints.
twMerge('hover:bg-red-500', 'hover:bg-blue-500');
// Result: 'hover:bg-blue-500'
// Same modifier, same property. Later wins.
twMerge('bg-red-500', 'hover:bg-blue-500');
// Result: 'bg-red-500 hover:bg-blue-500'
// Different modifiers, no conflict. Both kept.
twMerge('md:px-4', 'lg:px-6');
// Result: 'md:px-4 lg:px-6'
// Different breakpoints, no conflict.
This is what makes the library correct for responsive and state-based utilities. A consumer can override a base class only at a specific breakpoint, and the other breakpoints remain intact.
Arbitrary values
tailwind-merge handles arbitrary values, such as bg-[#1da1f2], by treating them as part of the same conflict group as the corresponding utility. bg-[#1da1f2] conflicts with bg-blue-500 because both set background color.
twMerge('bg-blue-500', 'bg-[#1da1f2]');
// Result: 'bg-[#1da1f2]'
twMerge('text-[14px]', 'text-sm');
// Result: 'text-sm'
// The later class wins, regardless of whether it uses an arbitrary value.
Arbitrary values are supported as long as they follow Tailwind’s syntax, with square brackets around the value.
The extendTailwindMerge configuration
If the project extends Tailwind with custom utilities or custom color names, tailwind-merge may not recognize them. The extendTailwindMerge function creates a customized merge function that knows about the project’s additional utilities.
import { extendTailwindMerge } from 'tailwind-merge';
const twMerge = extendTailwindMerge({
extend: {
classGroups: {
'font-size': [{ text: ['tiny', 'huge'] }],
},
},
});
twMerge('text-tiny', 'text-huge');
// Result: 'text-huge'
// The custom font sizes are recognized as the same conflict group.
Without this configuration, custom utilities are treated as non-conflicting and both are kept, which may produce unexpected results.
The cva integration
tailwind-merge is commonly paired with cva by wrapping the variant function’s output. The variant function produces the base and variant classes, and twMerge resolves the conflicts with the consumer’s className.
import { cva } from 'class-variance-authority';
import { twMerge } from 'tailwind-merge';
const button = cva('px-4 py-2 rounded', {
variants: {
intent: {
primary: 'bg-blue-600 text-white',
secondary: 'bg-gray-100 text-gray-900',
},
},
});
function Button({ intent, className }) {
return (
<button className={twMerge(button({ intent }), className)}>
Click
</button>
);
}
The cva call returns a class string, and twMerge merges it with the consumer’s override. The consumer’s px-6 wins over the base px-4.
What tailwind-merge does not do
The library does not remove duplicate non-conflicting classes. twMerge('flex flex') returns 'flex flex'. It does not validate that classes are valid Tailwind utilities. twMerge('not-a-real-class') keeps the class. It does not affect CSS specificity; it only resolves conflicts within the class string. The actual CSS cascade still applies.
twMerge('flex flex');
// Result: 'flex flex' (duplicates not removed)
twMerge('made-up-class');
// Result: 'made-up-class' (unknown classes are kept)
twMerge('!px-4', 'px-6');
// Result: 'px-6'
// Important modifiers are handled by conflict group.
b. class-variance-authority (cva)
cva is a function that defines a component’s class variants. It takes a base class string and a configuration object, and returns a function that produces the correct class string for a given set of variant props.
The basic structure
The cva function returns a variant function. The variant function accepts an object of variant selections and returns a class string.
import { cva } from 'class-variance-authority';
const button = cva('inline-flex items-center rounded font-medium', {
variants: {
intent: {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
danger: 'bg-red-600 text-white hover:bg-red-700',
},
size: {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
},
},
defaultVariants: {
intent: 'primary',
size: 'md',
},
});
button({ intent: 'primary', size: 'lg' });
// 'inline-flex items-center rounded font-medium bg-blue-600 text-white hover:bg-blue-700 px-6 py-3 text-lg'
The base classes are always included. The variant classes are added based on the selections. The defaultVariants are used when a variant is not specified.
Compound variants
A compound variant applies when a specific combination of variants is selected. This is how combinations that need special treatment are handled.
const button = cva('rounded', {
variants: {
intent: {
primary: 'bg-blue-600',
danger: 'bg-red-600',
},
size: {
sm: 'text-sm',
lg: 'text-lg',
},
},
compoundVariants: [
{
intent: 'primary',
size: 'lg',
class: 'shadow-lg',
},
],
defaultVariants: {
intent: 'primary',
size: 'sm',
},
});
button({ intent: 'primary', size: 'lg' });
// 'rounded bg-blue-600 text-lg shadow-lg'
The compound variant adds shadow-lg only when both intent: 'primary' and size: 'lg' are selected.
Boolean variants
A variant can be boolean, which is useful for states like disabled or loading.
const button = cva('rounded', {
variants: {
intent: {
primary: 'bg-blue-600',
},
disabled: {
true: 'opacity-50 cursor-not-allowed',
false: '',
},
},
defaultVariants: {
intent: 'primary',
disabled: false,
},
});
button({ intent: 'primary', disabled: true });
// 'rounded bg-blue-600 opacity-50 cursor-not-allowed'
The disabled variant has two options, true and false. When disabled: true is passed, the corresponding classes are applied.
TypeScript inference
cva provides TypeScript types for its variants. The VariantProps utility extracts the variant prop types from the cva configuration, so the component’s props are typed without manually declaring the unions.
import { cva, type VariantProps } from 'class-variance-authority';
const button = cva('rounded', {
variants: {
intent: {
primary: 'bg-blue-600',
secondary: 'bg-gray-100',
danger: 'bg-red-600',
},
size: {
sm: 'text-sm',
md: 'text-base',
lg: 'text-lg',
},
},
defaultVariants: {
intent: 'primary',
size: 'md',
},
});
type ButtonProps = VariantProps<typeof button> & {
className?: string;
children: React.ReactNode;
};
function Button({ intent, size, className, children }: ButtonProps) {
return (
<button className={button({ intent, size, className })}>
{children}
</button>
);
}
The intent prop is typed as 'primary' | 'secondary' | 'danger' | null | undefined, inferred from the cva configuration. Adding a new intent to the configuration automatically updates the type.
The className parameter
The cva function accepts a className parameter that is appended to the result. This is a convenience for the common case of merging the variant classes with a consumer-supplied class string.
button({ intent: 'primary', className: 'mt-4' });
// 'rounded bg-blue-600 mt-4'
However, this does not resolve conflicts. If the base class has px-4 and the consumer passes px-6 via className, both are present. For conflict resolution, the result should be passed through tailwind-merge.
The config object structure
The cva configuration has three top-level keys. variants defines each variant and its options. compoundVariants defines combinations that receive additional classes. defaultVariants defines the defaults for each variant.
const component = cva('base-classes', {
variants: {
variantName: {
optionA: 'classes-for-a',
optionB: 'classes-for-b',
},
},
compoundVariants: [
{
variantName: 'optionA',
otherVariant: 'optionX',
class: 'additional-classes',
},
],
defaultVariants: {
variantName: 'optionA',
},
});
The configuration is declarative. The order of keys in variants does not matter because the class strings are concatenated in the order the variants are declared.
c. Integrating tailwind-merge with cva
The two libraries are designed to work together. cva produces the class string for the selected variants, and tailwind-merge resolves conflicts between that string and the consumer’s overrides.
The standard wrapper pattern
The idiomatic pattern is to define the cva function, then wrap it in a component that merges the result with the consumer’s className.
import { cva, type VariantProps } from 'class-variance-authority';
import { twMerge } from 'tailwind-merge';
const buttonVariants = cva(
'inline-flex items-center justify-center rounded font-medium transition-colors',
{
variants: {
intent: {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
danger: 'bg-red-600 text-white hover:bg-red-700',
},
size: {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
},
},
defaultVariants: {
intent: 'primary',
size: 'md',
},
}
);
type ButtonProps = VariantProps<typeof buttonVariants> & {
className?: string;
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
function Button({ intent, size, className, ...props }: ButtonProps) {
return (
<button
className={twMerge(buttonVariants({ intent, size }), className)}
{...props}
/>
);
}
The component accepts the variant props, the className prop, and any native button attributes. The cva function is called with the variant props, and twMerge combines the result with the consumer’s className. The native attributes are spread onto the button.
The classNames object pattern
For components with multiple elements, such as a dropdown with a trigger and a menu, the consumer needs to override classes for each element independently. The pattern is to accept a classNames object.
type DropdownProps = {
classNames?: {
trigger?: string;
menu?: string;
item?: string;
};
};
function Dropdown({ classNames = {} }: DropdownProps) {
return (
<div>
<button className={twMerge('rounded px-4 py-2', classNames.trigger)}>
Toggle
</button>
<ul className={twMerge('mt-1 rounded shadow', classNames.menu)}>
<li className={twMerge('px-4 py-2', classNames.item)}>Item</li>
</ul>
</div>
);
}
Each element has its own merge point. The consumer can override the trigger, the menu, the items, or any combination.
The variant-plus-override pattern
The most common pattern combines variants with overrides. The consumer selects a variant via prop and overrides specific utilities via className. The variant provides the base, and the override takes precedence.
<Button intent="danger" size="lg" className="mt-4 px-8">
Delete
</Button>
The component computes buttonVariants({ intent: 'danger', size: 'lg' }), which produces bg-red-600 text-white px-6 py-3 text-lg. Then twMerge merges this with mt-4 px-8. The result drops px-6 in favor of px-8, keeps py-3, and adds mt-4. The consumer’s horizontal padding override works correctly.
The forwardRef consideration
When wrapping the component with forwardRef, the className merging happens inside the forwarded render function. The pattern is unchanged.
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ intent, size, className, ...props }, ref) => {
return (
<button
ref={ref}
className={twMerge(buttonVariants({ intent, size }), className)}
{...props}
/>
);
}
);
The ref is forwarded to the native element. The className is merged as before.
The asChild consideration
When the component supports an asChild prop, the merged classes must be applied to the child element rather than the default element. The merge logic is the same; the target is different.
function Slot({ children, className, ...props }) {
return React.cloneElement(children, {
...props,
className: twMerge(className, children.props.className),
});
}
function Button({ asChild, className, ...props }) {
const merged = twMerge(buttonVariants(props), className);
if (asChild) {
return <Slot className={merged} {...props}>{/* child */}</Slot>;
}
return <button className={merged} {...props} />;
}
The merged class string is applied to either the default button or the consumer’s child element.
Complete Example Session
// ============================================
// PART 1: THE CONFLICT PROBLEM
// ============================================
// A simple component with base classes and a className prop.
function BadButton({ className, children }) {
return (
<button className={`px-4 py-2 rounded ${className}`}>
{children}
</button>
);
}
// Consumer passes px-6:
<BadButton className="px-6">Click</BadButton>
// Rendered class: "px-4 py-2 rounded px-6"
// Both px-4 and px-6 are present.
// CSS order determines the winner, not the consumer's intent.
// This is unpredictable and often wrong.
// ============================================
// PART 2: THE tailwind-merge SOLUTION
// ============================================
import { twMerge } from 'tailwind-merge';
function GoodButton({ className, children }) {
return (
<button className={twMerge('px-4 py-2 rounded', className)}>
{children}
</button>
);
}
// Consumer passes px-6:
<GoodButton className="px-6">Click</GoodButton>
// Rendered class: "py-2 rounded px-6"
// px-4 was removed. px-6 wins.
// py-2 and rounded were kept because they do not conflict.
// ============================================
// PART 3: MODIFIER AND RESPONSIVE CONFLICTS
// ============================================
twMerge('bg-red-500', 'hover:bg-blue-500');
// 'bg-red-500 hover:bg-blue-500'
// Different modifiers, both kept.
twMerge('hover:bg-red-500', 'hover:bg-blue-500');
// 'hover:bg-blue-500'
// Same modifier, later wins.
twMerge('px-4', 'md:px-6');
// 'px-4 md:px-6'
// Different breakpoints, both kept.
twMerge('md:px-4', 'md:px-6');
// 'md:px-6'
// Same breakpoint, later wins.
twMerge('md:px-4 lg:px-4', 'md:px-6');
// 'lg:px-4 md:px-6'
// Only md:px-4 conflicts with md:px-6.
// lg:px-4 is on a different breakpoint and is kept.
// ============================================
// PART 4: THE VARIANT EXPLOSION PROBLEM
// ============================================
// Without cva, variant logic becomes a tangled mess.
function MessyButton({ intent, size, disabled, className, children }) {
const base = 'inline-flex items-center rounded font-medium';
const intentClass =
intent === 'primary' ? 'bg-blue-600 text-white' :
intent === 'secondary' ? 'bg-gray-100 text-gray-900' :
intent === 'danger' ? 'bg-red-600 text-white' :
'bg-blue-600 text-white';
const sizeClass =
size === 'sm' ? 'px-3 py-1.5 text-sm' :
size === 'md' ? 'px-4 py-2 text-base' :
size === 'lg' ? 'px-6 py-3 text-lg' :
'px-4 py-2 text-base';
const disabledClass = disabled ? 'opacity-50 cursor-not-allowed' : '';
return (
<button className={twMerge(base, intentClass, sizeClass, disabledClass, className)}>
{children}
</button>
);
}
// Works, but verbose. The default fallbacks are easy to forget.
// The TypeScript types are not inferred.
// ============================================
// PART 5: THE cva SOLUTION
// ============================================
import { cva, type VariantProps } from 'class-variance-authority';
const buttonVariants = cva(
'inline-flex items-center justify-center rounded font-medium transition-colors focus:outline-none focus:ring-2',
{
variants: {
intent: {
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-400',
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
},
size: {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
},
disabled: {
true: 'opacity-50 cursor-not-allowed pointer-events-none',
false: '',
},
},
defaultVariants: {
intent: 'primary',
size: 'md',
disabled: false,
},
}
);
type ButtonProps = VariantProps<typeof buttonVariants> & {
className?: string;
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
function Button({ intent, size, disabled, className, ...props }: ButtonProps) {
return (
<button
className={twMerge(buttonVariants({ intent, size, disabled }), className)}
disabled={disabled}
{...props}
/>
);
}
// ============================================
// PART 6: COMPOUND VARIANTS
// ============================================
const alertVariants = cva('rounded-lg border p-4', {
variants: {
severity: {
info: 'border-blue-200 bg-blue-50 text-blue-900',
warning: 'border-yellow-200 bg-yellow-50 text-yellow-900',
error: 'border-red-200 bg-red-50 text-red-900',
},
size: {
sm: 'text-sm',
lg: 'text-lg',
},
},
compoundVariants: [
{
severity: 'error',
size: 'lg',
class: 'border-2 shadow-lg',
},
{
severity: 'warning',
size: 'sm',
class: 'font-medium',
},
],
defaultVariants: {
severity: 'info',
size: 'sm',
},
});
// error + lg applies the compound variant classes.
alertVariants({ severity: 'error', size: 'lg' });
// 'rounded-lg border p-4 border-red-200 bg-red-50 text-red-900 text-lg border-2 shadow-lg'
// ============================================
// PART 7: THE FULL INTEGRATION
// ============================================
// The variant function produces classes.
// tailwind-merge resolves conflicts with the consumer's className.
function FullExample() {
return (
<div className="space-y-4 p-8">
<Button>Default</Button>
<Button intent="secondary" size="lg">Large Secondary</Button>
<Button intent="danger" className="px-8">Wide Danger</Button>
<Button disabled>Disabled</Button>
</div>
);
}
// The default button:
// buttonVariants({}) → base + primary + md + disabled=false
// twMerge(base+primary+md, undefined) → same
// The "Wide Danger" button:
// buttonVariants({ intent: 'danger' }) → base + danger + md
// twMerge(base+danger+md, 'px-8')
// → base + danger + md with px-6 replaced by px-8
// The "Large Secondary" button:
// buttonVariants({ intent: 'secondary', size: 'lg' })
// → base + secondary + lg + disabled=false
// ============================================
// PART 8: EXTENDING tailwind-merge
// ============================================
// When the project adds custom utilities, tailwind-merge needs to know.
import { extendTailwindMerge } from 'tailwind-merge';
const twMerge = extendTailwindMerge({
extend: {
classGroups: {
'font-size': [{ text: ['tiny', 'huge'] }],
'text-color': [{ text: ['brand', 'accent'] }],
},
},
});
// Now twMerge understands the custom utilities.
twMerge('text-tiny', 'text-huge');
// 'text-huge'
twMerge('text-brand', 'text-accent');
// 'text-accent'
// Without the extension, both classes would be kept because
// tailwind-merge would not know they conflict.
// ============================================
// PART 9: THE classNames OBJECT PATTERN
// ============================================
interface CardProps {
classNames?: {
root?: string;
header?: string;
title?: string;
body?: string;
};
}
function Card({ classNames = {}, children }: CardProps) {
return (
<div className={twMerge('rounded-lg border bg-white shadow-sm', classNames.root)}>
<div className={twMerge('border-b px-6 py-4', classNames.header)}>
<h2 className={twMerge('text-lg font-semibold', classNames.title)}>
Title
</h2>
</div>
<div className={twMerge('px-6 py-4', classNames.body)}>
{children}
</div>
</div>
);
}
// Consumer overrides specific parts:
<Card
classNames={{
root: 'max-w-md border-blue-500',
title: 'text-xl text-blue-700',
}}
>
Content
</Card>
// ============================================
// PART 10: THE COMPLETE PATTERN
// ============================================
// The full component pattern:
// 1. Define cva variants.
// 2. Extract VariantProps type.
// 3. Create the component.
// 4. Merge variant output with className via twMerge.
// 5. Spread native props.
// 6. Forward ref if needed.
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { twMerge } from 'tailwind-merge';
const badgeVariants = cva(
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors',
{
variants: {
variant: {
default: 'bg-gray-100 text-gray-800',
success: 'bg-green-100 text-green-800',
warning: 'bg-yellow-100 text-yellow-800',
error: 'bg-red-100 text-red-800',
},
size: {
sm: 'px-2 py-0.5 text-xs',
md: 'px-2.5 py-0.5 text-sm',
},
},
defaultVariants: {
variant: 'default',
size: 'sm',
},
}
);
type BadgeProps = VariantProps<typeof badgeVariants> & {
className?: string;
} & React.HTMLAttributes<HTMLSpanElement>;
const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(
({ variant, size, className, ...props }, ref) => (
<span
ref={ref}
className={twMerge(badgeVariants({ variant, size }), className)}
{...props}
/>
)
);
Badge.displayName = 'Badge';
// Usage:
<Badge variant="success">Active</Badge>
<Badge variant="error" size="md" className="font-bold">Failed</Badge>
// The font-bold overrides the font-medium from the base.
// The size md is applied, and the base text-xs is replaced by text-sm.
The ten parts progressed from the conflict problem through tailwind-merge, modifier handling, the variant explosion, cva, compound variants, the full integration, extending tailwind-merge, the classNames object pattern, and the complete production pattern. The same button and badge scenarios ran through every part, showing how the two libraries divide the work.
Quick Reference
tailwind-merge API
| Function | Purpose |
|---|---|
twMerge(...classes) | Merge and resolve conflicts |
extendTailwindMerge(config) | Create custom merge function |
getDefaultConfig() | Access the default configuration |
cva API
| Option | Purpose |
|---|---|
variants | Define each variant and its options |
compoundVariants | Define combinations with extra classes |
defaultVariants | Define defaults for each variant |
VariantProps<typeof fn> | Extract TypeScript types |
Common Conflict Groups
| Group | Example Conflict | Winner |
|---|---|---|
| Padding | px-4 vs px-6 | Later |
| Margin | mt-2 vs mt-4 | Later |
| Background | bg-red-500 vs bg-blue-500 | Later |
| Text size | text-sm vs text-lg | Later |
| Border radius | rounded vs rounded-lg | Later |
| Display | flex vs hidden | Later |
| Modifier-specific | hover:bg-red-500 vs hover:bg-blue-500 | Later |
| Responsive | md:px-4 vs md:px-6 | Later |
| Different modifiers | bg-red-500 vs hover:bg-blue-500 | Both kept |
| Different breakpoints | md:px-4 vs lg:px-6 | Both kept |
Integration Pattern
| Step | Action |
|---|---|
| 1 | Define cva with base, variants, defaults |
| 2 | Extract VariantProps type |
| 3 | Create component with variant props and className |
| 4 | Call cva with variant props |
| 5 | Wrap with twMerge including className |
| 6 | Spread native props and forward ref |
Best Practices
✅ Do This:
// Use twMerge for all className merging.
twMerge(base, className); // ✅
// Define variants with cva.
const button = cva('base', { variants: { intent: {...} } }); // ✅
// Extract types from the cva configuration.
type Props = VariantProps<typeof button> & { className?: string }; // ✅
// Use compoundVariants for special combinations.
compoundVariants: [{ intent: 'primary', size: 'lg', class: 'shadow-lg' }]; // ✅
// Extend tailwind-merge for custom utilities.
const twMerge = extendTailwindMerge({ extend: { classGroups: {...} } }); // ✅
// Accept a classNames object for multi-element components.
classNames?: { root?: string; header?: string }; // ✅
// Keep the merge order: variants first, className last.
twMerge(button({ intent }), className); // ✅
// Forward ref and spread native props.
React.forwardRef((props, ref) => <button ref={ref} {...props} />); // ✅
❌ Don’t Do This:
// Don't concatenate class strings without merging.
`px-4 ${className}`; // ❌ Conflicts unresolved
// Don't use cva's className parameter without twMerge.
button({ className: 'px-6' }); // ❌ Conflict may not resolve
// Don't skip defaultVariants.
cva('base', { variants: { intent: {...} } }); // ❌ No fallback
// Don't manually declare variant unions.
type Intent = 'primary' | 'secondary'; // ❌ Use VariantProps
// Don't forget to extend tailwind-merge for custom utilities.
twMerge('text-tiny', 'text-huge'); // ❌ Both kept if not extended
// Don't spread native props before the className.
<button {...props} className={...} />; // ❌ Consumer className lost
// Don't use inline conditional logic for variants.
className={intent === 'primary' ? '...' : '...'}; // ❌ Use cva
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Override does not work | No conflict resolution | Use twMerge |
| Custom utilities kept twice | tailwind-merge does not know them | Use extendTailwindMerge |
| Variant fallback missing | defaultVariants not defined | Define defaults |
| TypeScript errors | Manual type declaration | Use VariantProps |
| Compound variant not applied | Incorrect key in compoundVariants | Match variant names exactly |
| className lost | Spread before className | Spread props first |
| Responsive override fails | Missing modifier in merge | Ensure modifiers match |
| Duplicate classes remain | Non-conflicting duplicates | twMerge does not dedupe |
Real-World Examples
1. Basic Merge
twMerge('px-4 py-2', 'px-6');
// 'py-2 px-6'
2. Modifier-Aware Merge
twMerge('hover:bg-red-500', 'hover:bg-blue-500');
// 'hover:bg-blue-500'
3. Responsive Merge
twMerge('md:px-4', 'lg:px-6');
// 'md:px-4 lg:px-6'
4. Simple Variants
const button = cva('rounded', {
variants: { intent: { primary: 'bg-blue-600', danger: 'bg-red-600' } },
});
5. Compound Variants
compoundVariants: [{ intent: 'primary', size: 'lg', class: 'shadow-lg' }]
6. Boolean Variants
variants: { disabled: { true: 'opacity-50', false: '' } }
7. Type Extraction
type Props = VariantProps<typeof button> & { className?: string };
8. Extending tailwind-merge
const twMerge = extendTailwindMerge({
extend: { classGroups: { 'font-size': [{ text: ['tiny'] }] } },
});
9. classNames Object
<Card classNames={{ root: 'max-w-md', title: 'text-xl' }} />
10. Full Component Pattern
const Badge = React.forwardRef(({ variant, className, ...props }, ref) => (
<span ref={ref} className={twMerge(badge({ variant }), className)} {...props} />
));
Visual
The Merging Pipeline
┌────────────────────────────────────────────────────────────┐
│ THE MERGING PIPELINE │
│ │
│ Variant props Consumer className │
│ ┌──────────┐ ┌──────────────┐ │
│ │ intent: │ │ "px-8 mt-4" │ │
│ │ danger │ └──────┬───────┘ │
│ │ size: lg │ │ │
│ └────┬─────┘ │ │
│ │ │ │
│ v │ │
│ ┌────────────────────┐ │ │
│ │ cva() │ │ │
│ │ base + danger + lg │ │ │
│ │ "rounded bg-red-600│ │ │
│ │ px-6 py-3 text-lg"│ │ │
│ └─────────┬──────────┘ │ │
│ │ │ │
│ └──────┬──────┘ │
│ │ │
│ v │
│ ┌──────────────────────────────────────────┐ │
│ │ twMerge() │ │
│ │ Resolves conflicts: │ │
│ │ px-6 vs px-8 → px-8 wins │ │
│ │ py-3 kept (no conflict) │ │
│ │ mt-4 kept (no conflict) │ │
│ └──────────────────────────────────────────┘ │
│ │ │
│ v │
│ ┌──────────────────────────────────────────┐ │
│ │ Final: "rounded bg-red-600 px-8 py-3 │ │
│ │ text-lg mt-4" │ │
│ └──────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────┘
Conflict Resolution Rules
┌────────────────────────────────────────────────────────────┐
│ CONFLICT RESOLUTION RULES │
│ │
│ SAME GROUP, SAME MODIFIER → LATER WINS │
│ ┌──────────────────────────────────────────────────┐ │
│ │ px-4 vs px-6 → px-6 │ │
│ │ bg-red-500 vs bg-blue-500 → bg-blue-500 │ │
│ │ hover:px-4 vs hover:px-6 → hover:px-6 │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ DIFFERENT MODIFIER → BOTH KEPT │
│ ┌──────────────────────────────────────────────────┐ │
│ │ bg-red-500 vs hover:bg-blue-500 → both kept │ │
│ │ px-4 vs md:px-6 → both kept │ │
│ │ md:px-4 vs lg:px-6 → both kept │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ DIFFERENT GROUP → BOTH KEPT │
│ ┌──────────────────────────────────────────────────┐ │
│ │ px-4 vs py-2 → both kept │ │
│ │ bg-red-500 vs text-white → both kept │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ BROADER vs NARROWER → NARROWER WINS │
│ ┌──────────────────────────────────────────────────┐ │
│ │ p-4 vs px-6 → px-6 (narrower wins) │ │
│ │ px-4 py-2 vs p-6 → p-6 (later, broader wins) │ │
│ └──────────────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────┘
cva Configuration Structure
┌────────────────────────────────────────────────────────────┐
│ cva CONFIGURATION STRUCTURE │
│ │
│ cva( │
│ 'base classes always applied', ← base │
│ { │
│ variants: { ← variants │
│ intent: { │
│ primary: 'bg-blue-600', │
│ danger: 'bg-red-600', │
│ }, │
│ size: { │
│ sm: 'text-sm', │
│ lg: 'text-lg', │
│ }, │
│ }, │
│ compoundVariants: [ ← combinations │
│ { │
│ intent: 'primary', │
│ size: 'lg', │
│ class: 'shadow-lg', │
│ }, │
│ ], │
│ defaultVariants: { ← defaults │
│ intent: 'primary', │
│ size: 'sm', │
│ }, │
│ } │
│ ) │
│ │
│ Returns a function: │
│ fn({ intent, size, className }) → class string │
│ │
└────────────────────────────────────────────────────────────┘
Integration with TypeScript
┌────────────────────────────────────────────────────────────┐
│ TYPE INFERENCE FROM cva │
│ │
│ const button = cva('base', { │
│ variants: { │
│ intent: { │
│ primary: '...', │
│ danger: '...', │
│ }, │
│ }, │
│ }); │
│ │
│ type ButtonProps = VariantProps<typeof button>; │
│ │
│ TypeScript infers: │
│ ┌──────────────────────────────────────────────────┐ │
│ │ ButtonProps = { │ │
│ │ intent?: 'primary' | 'danger' | null | undefined│ │
│ │ } │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ Adding a new intent to the cva config │
│ automatically updates the type. │
│ │
│ <Button intent="primary" /> ← valid │
│ <Button intent="danger" /> ← valid │
│ <Button intent="typo" /> ← TypeScript error │
│ │
└────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
tailwind-merge | Resolves conflicts between utility classes |
twMerge | The merge function; later classes win |
| Conflict group | Utilities targeting the same CSS property |
| Modifier awareness | hover:, focus:, md: treated separately |
extendTailwindMerge | Custom merge function for project utilities |
cva | Defines component variants declaratively |
variants | Each variant and its options |
compoundVariants | Combinations that receive extra classes |
defaultVariants | Fallback values for each variant |
VariantProps | TypeScript utility extracting variant types |
| Merge order | Variants first, consumer className last |
Key takeaways:
tailwind-mergesolves the conflict problem. Without it, concatenating class strings produces unpredictable overrides. With it, later classes win deterministically, and the consumer’s overrides always take precedence.tailwind-mergeis modifier-aware. It distinguisheshover:px-4frompx-4andmd:px-4fromlg:px-4. Responsive and state-based overrides work correctly.cvastructures variants declaratively. Base classes, variant options, compound variants, and defaults are defined in one configuration. The function returns the correct class string for any combination.VariantPropsinfers TypeScript types. The variant unions are derived from the cva configuration, so adding a new variant automatically updates the component’s props type.- The two libraries are complementary, not redundant.
cvaproduces the class string for the selected variants.tailwind-mergeresolves conflicts with the consumer’s overrides. Neither replaces the other. - The merge order matters. The cva output is the first argument, and the consumer’s className is the last. Later classes win, so the consumer always overrides the component’s defaults.
- Custom utilities need
extendTailwindMerge. If the project adds utilities outside the standard Tailwind set, the merge function must be extended to recognize them. Otherwise, conflicts involving those utilities go unresolved. - The
classNamesobject pattern handles multi-element components. For components with multiple parts, each part gets its own merge point and its own override slot.
Remember: tailwind-merge and cva solve two halves of the same problem. cva organizes the variant logic: it defines what classes apply for each combination of props and produces a clean class string. tailwind-merge resolves the conflicts: it ensures that when a component’s base classes and a consumer’s overrides target the same property, the consumer’s intent wins. Together they form the standard pattern for composable Tailwind components: define variants with cva, merge the result with the consumer’s className using twMerge, and let the consumer override anything. The pattern is consistent, typed, and predictable. The component ships with sensible defaults, the consumer overrides with confidence, and the CSS cascade is never the deciding factor.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!