EmpireUI
Get Pro
← Blog8 min read#tailwind-merge#tailwind#conflict resolution

tailwind-merge: Resolve Conflicting Tailwind Classes Safely

tailwind-merge fixes the class conflict problem that bites every Tailwind component library. Here's exactly how it works, when to reach for it, and how to wire it up in TypeScript.

developer writing TypeScript code on a laptop screen at desk

The Class Conflict Problem Nobody Warns You About

You build a reusable Button component. You give it px-4 py-2 as defaults. A consumer passes in px-8 to override the padding. The rendered HTML now has px-4 px-8 on the same element — and Tailwind's output just picks whichever one lands last in the generated CSS file. That might not be the one you passed last in the JSX. That's the bug. And it's silent, so it eats 30 minutes of your afternoon before you figure out why the button looks wrong only in production.

Tailwind doesn't have a cascade-like specificity model for utility conflicts. The browser applies whichever class appears later in the stylesheet, which is determined by the order Tailwind generates them — not the order you write them in className. This surprises almost everyone who tries to build a composable component API on top of Tailwind.

Look, you could work around this with CSS-in-JS or inline styles, but that defeats the whole point. The correct fix is tailwind-merge. It's a 6 kB library (minified) that understands Tailwind's class semantics well enough to intelligently drop conflicting classes before they reach the DOM.

Worth noting: this isn't an edge case. Any component library built on Tailwind v3 or v4 that accepts a className prop hits this. Empire UI runs into exactly this scenario with every component that supports style overrides — it's one of the first utilities you'd install when browsing components meant to be extended.

What tailwind-merge Actually Does

tailwind-merge parses the class string you give it, groups classes by the CSS property they control, and when two classes conflict it keeps the last one. That's the mental model. twMerge('px-4 px-8') returns 'px-8'. twMerge('text-red-500 text-blue-600') returns 'text-blue-600'. It understands that p-4 conflicts with px-2 (because padding-inline is a subset of padding), so twMerge('p-4 px-2') returns 'p-4 px-2' — only the sides that overlap get resolved.

The library ships full TypeScript types and works with Tailwind v3.0 through the latest v4.x. It handles arbitrary values (text-[14px]), modifiers (hover:bg-blue-500), and responsive prefixes (md:flex md:blockmd:block). That last one tripped a lot of people up in 2024 when responsive variants started getting merged wrongly by naive string concatenation helpers.

import { twMerge } from 'tailwind-merge';

// Basic conflict resolution
twMerge('px-4 px-8')          // → 'px-8'
twMerge('text-sm text-base')   // → 'text-base'

// Handles modifiers correctly
twMerge('hover:bg-red-500 hover:bg-blue-500') // → 'hover:bg-blue-500'

// Responsive prefixes are grouped by variant
twMerge('md:flex md:block')    // → 'md:block'

// Non-conflicting classes pass through untouched
twMerge('flex items-center px-4 px-8') // → 'flex items-center px-8'

One more thing — tailwind-merge is smart about shorthand vs longhand properties. p-4 sets all four sides; px-2 only sets horizontal padding. These don't conflict, so both survive. Compare that to px-4 px-8 where both target horizontal padding — only the last one makes it through. Getting this right without false positives is genuinely hard, which is why you shouldn't try to roll your own regex-based version.

Installation and Basic Wiring

Installation is one line. The package has zero runtime dependencies.

npm install tailwind-merge
# or
pnpm add tailwind-merge
# or
yarn add tailwind-merge

The most common pattern in a component library is to create a cn utility function that combines clsx (for conditional class logic) with twMerge (for conflict resolution). You've probably seen this in shadcn/ui — it's become de facto standard in the Tailwind ecosystem since around 2023.

// lib/cn.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

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

With this in place, every component uses cn() instead of template literals or manual string joins. Your Button now looks like this:

// Button.tsx
import { cn } from '@/lib/cn';

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'ghost';
}

export function Button({ variant = 'primary', className, ...props }: ButtonProps) {
  return (
    <button
      className={cn(
        'inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium transition-colors',
        variant === 'primary' && 'bg-violet-600 text-white hover:bg-violet-700',
        variant === 'ghost' && 'bg-transparent text-gray-700 hover:bg-gray-100',
        className, // caller overrides land last — conflicts auto-resolved
      )}
      {...props}
    />
  );
}

// Caller usage:
// <Button className="px-8" /> → internal px-4 is dropped, px-8 wins

That className at the end of the cn() call is intentional. Caller classes go last so they take precedence over defaults. tailwind-merge sees px-4 ... px-8 in that order and keeps px-8. The component author's intent is preserved, and the consumer's override actually works.

Extending the Config for Custom Classes

Out of the box, tailwind-merge knows about every class in the default Tailwind v3/v4 palette. But if you've added custom utilities via @layer utilities or a plugin — say, text-balance, a custom shadow-glass for glassmorphism components, or a design-token-driven color scale — the library won't know they conflict with anything. You have to tell it.

The extendTailwindMerge function lets you describe your custom classes so the resolver handles them correctly. This is especially relevant if you run a design system with proprietary tokens.

import { extendTailwindMerge } from 'tailwind-merge';

const twMerge = extendTailwindMerge({
  extend: {
    classGroups: {
      // Tell tailwind-merge that these are all "shadow" variants
      // so shadow-glass conflicts with shadow-lg, shadow-none, etc.
      shadow: ['shadow-glass', 'shadow-frosted', 'shadow-elevated'],
      // Custom font-size scale
      'font-size': ['text-2xs', 'text-3xs'],
    },
  },
});

// Now this works correctly:
twMerge('shadow-lg shadow-glass'); // → 'shadow-glass'

In practice, most teams skip this step for months until something breaks. The fix takes five minutes once you know it exists. Quick aside: if you're using a prefix (like tw- via Tailwind's prefix config option), pass { prefix: 'tw-' } to extendTailwindMerge or use the withTw helper so the resolver matches your prefixed class names correctly.

Honestly, you'll also want to memoize twMerge if you're calling it inside components that re-render frequently. The library ships a createTailwindMerge factory specifically for this — create your configured instance once at module level, not inside a component body.

Common Gotchas and Edge Cases

The biggest footgun is ordering. twMerge('px-8 px-4') gives you px-4 because it's last. This isn't a bug — it's intentional, last-write-wins semantics. But it means if you build a variant system where variants are applied after the base but before className, you need to be deliberate about order in your cn() call.

Another one: tailwind-merge v2 (released late 2023) changed the internal config structure significantly from v1. If you're still on v1.5.x and using extendTailwindMerge, the config shape is different. Worth checking your package.json — a lot of projects were quietly pinned to v1 by transitive deps and are missing the Tailwind v3.3+ class coverage that landed in v2.

// GOTCHA: order matters — this drops px-8, not px-4
twMerge('px-8 px-4'); // → 'px-4'

// CORRECT pattern: put overrides last
function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

// defaults first, overrides last — always
cn('px-4 py-2 text-sm', isLarge && 'px-8 text-base', externalClassName);

Arbitrary values work fine in most cases (bg-[#1a1a2e], w-[320px]), but the library can't know if two arbitrary values for the same property conflict unless you're explicit. bg-[#ff0000] bg-[#00ff00] does correctly resolve to bg-[#00ff00] — it groups arbitrary values by the property prefix. Where it fails is if you mix a named class with an arbitrary one: text-red-500 text-[#ff0000] resolves to text-[#ff0000] correctly. That one actually works great.

What doesn't work: mixing properties that share no common group. You won't accidentally merge flex and grid into just grid — they're different display values, so both survive. Which is correct behavior. The concern is only within a single CSS property's utility group.

Performance: Is It Worth the Weight?

tailwind-merge minified is about 6.3 kB as of v2.5. That's smaller than a single Medium-resolution icon. It parses class strings using a finite-state machine with an internal cache, so repeated identical inputs are O(1) after the first pass. In a component library rendering thousands of instances, the cache warms up fast and the overhead becomes negligible.

That said, you can reduce cost further by calling twMerge outside of render when your class strings are static. If a component's classes never change at runtime, compute the merged string once at module initialization and reference the result. Same principle as memoizing expensive computations — useMemo works if the inputs are reactive.

// Static classes — compute once outside component
const BASE_CLASSES = twMerge(
  'inline-flex items-center rounded-xl',
  'bg-violet-600 text-white px-4 py-2',
);

function StaticButton({ className }: { className?: string }) {
  // Only merge when caller provides overrides
  const classes = className ? twMerge(BASE_CLASSES, className) : BASE_CLASSES;
  return <button className={classes}>Click</button>;
}

For component libraries like Empire UI where the same base classes repeat across hundreds of component instances, this pattern meaningfully cuts redundant string parsing. It's not micro-optimization territory — it's just sensible. The gradient generator and other interactive tools do similar class hoisting to keep the UI snappy on lower-end devices.

tailwind-merge + cva: The Full Setup

If you're building a serious component system, you'll eventually want class-variance-authority (cva) alongside tailwind-merge. cva handles the variant API — defining size, intent, and visual variants with their associated class sets. twMerge handles the conflict resolution when those variants combine with external className props. They solve different problems and compose perfectly.

import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/cn'; // your twMerge + clsx combo

const button = cva(
  // base classes
  'inline-flex items-center justify-center font-medium transition-colors',
  {
    variants: {
      intent: {
        primary: 'bg-violet-600 text-white hover:bg-violet-700',
        ghost: 'bg-transparent border border-gray-300 hover:bg-gray-50',
        danger: 'bg-red-600 text-white hover:bg-red-700',
      },
      size: {
        sm: 'rounded-md px-3 py-1.5 text-xs',
        md: 'rounded-lg px-4 py-2 text-sm',
        lg: 'rounded-xl px-6 py-3 text-base',
      },
    },
    defaultVariants: { intent: 'primary', size: 'md' },
  },
);

interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof button> {}

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

This is the pattern that scales. cva gives you type-safe variant enumeration. cn (with twMerge under the hood) gives you safe external overrides. You don't have to think about ordering within cva — it handles that. You just have to put className last in cn(), which is a one-time mental rule.

Worth noting: cva v1.0 was released in early 2025 and cleaned up the compound variants API significantly. If you're on v0.x, the migration is small but the new compoundVariants key allows you to apply classes only when two or more variants match simultaneously — which is genuinely useful for things like size='lg' + intent='ghost' needing different border widths.

Is this the most complex setup for "just adding some classes"? Yeah. But if you're shipping a design system to a team of developers, the alternative is a support queue full of "why isn't my padding override working" questions. The upfront investment is 20 minutes. The payoff is a component API that actually behaves predictably.

FAQ

Does tailwind-merge work with Tailwind v4?

Yes. tailwind-merge v2.5+ supports Tailwind v4's new class naming conventions, including the updated opacity syntax and the CSS-first config approach. Check the changelog for your exact version pairing — v4 introduced some class renames that required updates in the resolver.

Do I still need clsx if I'm using tailwind-merge?

They do different things. clsx handles conditional class logic (truthy/falsy expressions, arrays, objects). tailwind-merge handles conflict resolution. You typically want both — the standard cn helper wraps clsx output with twMerge so you get both features in one call.

Will tailwind-merge slow down my app?

Not meaningfully. It uses an internal LRU cache so repeated identical class strings are resolved in O(1) after the first parse. For truly static class sets you can hoist the twMerge call outside the component entirely and pay the cost exactly once at module load.

How does tailwind-merge handle classes it doesn't recognize?

Unknown classes pass through untouched and are never dropped. So custom classes or third-party plugin classes you haven't registered via extendTailwindMerge will still appear in the output — they just won't participate in conflict resolution. Register them if you need them to override each other correctly.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

clsx and cn() in Tailwind Projects: Conditional Classes Without Chaostw-merge + clsx: Conditional Classes Without Specificity BugsReact UI Components Complete Reference: 60+ Patterns with CodeBuilding Design Systems That Scale: Engineering Guide 2026