| |

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

FunctionPurpose
twMerge(...classes)Merge and resolve conflicts
extendTailwindMerge(config)Create custom merge function
getDefaultConfig()Access the default configuration

cva API

OptionPurpose
variantsDefine each variant and its options
compoundVariantsDefine combinations with extra classes
defaultVariantsDefine defaults for each variant
VariantProps<typeof fn>Extract TypeScript types

Common Conflict Groups

GroupExample ConflictWinner
Paddingpx-4 vs px-6Later
Marginmt-2 vs mt-4Later
Backgroundbg-red-500 vs bg-blue-500Later
Text sizetext-sm vs text-lgLater
Border radiusrounded vs rounded-lgLater
Displayflex vs hiddenLater
Modifier-specifichover:bg-red-500 vs hover:bg-blue-500Later
Responsivemd:px-4 vs md:px-6Later
Different modifiersbg-red-500 vs hover:bg-blue-500Both kept
Different breakpointsmd:px-4 vs lg:px-6Both kept

Integration Pattern

StepAction
1Define cva with base, variants, defaults
2Extract VariantProps type
3Create component with variant props and className
4Call cva with variant props
5Wrap with twMerge including className
6Spread 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

PitfallWhy It HappensFix
Override does not workNo conflict resolutionUse twMerge
Custom utilities kept twicetailwind-merge does not know themUse extendTailwindMerge
Variant fallback missingdefaultVariants not definedDefine defaults
TypeScript errorsManual type declarationUse VariantProps
Compound variant not appliedIncorrect key in compoundVariantsMatch variant names exactly
className lostSpread before classNameSpread props first
Responsive override failsMissing modifier in mergeEnsure modifiers match
Duplicate classes remainNon-conflicting duplicatestwMerge 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

ItemValue
tailwind-mergeResolves conflicts between utility classes
twMergeThe merge function; later classes win
Conflict groupUtilities targeting the same CSS property
Modifier awarenesshover:, focus:, md: treated separately
extendTailwindMergeCustom merge function for project utilities
cvaDefines component variants declaratively
variantsEach variant and its options
compoundVariantsCombinations that receive extra classes
defaultVariantsFallback values for each variant
VariantPropsTypeScript utility extracting variant types
Merge orderVariants first, consumer className last

Key takeaways:

  • tailwind-merge solves 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-merge is modifier-aware. It distinguishes hover:px-4 from px-4 and md:px-4 from lg:px-4. Responsive and state-based overrides work correctly.
  • cva structures 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.
  • VariantProps infers 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. cva produces the class string for the selected variants. tailwind-merge resolves 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 classNames object 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!