← All Blogs

Why tailwind-merge Removes Your Tailwind Classes (and How to Fix It)

Two conflicting Tailwind classes merging into one resolved class with cn()

by Abdelkader Settah

November 19, 2024

Last updated: October 2, 2026

Resolving Tailwind CSS Class Conflicts with twMerge and Custom Configuration

If you’ve worked with tools like tailwind-merge or Shadcn, you may have encountered cases where some Tailwind classes conflict with each other. In this blog, I’ll explain why this happens and how to resolve it.

We’ll start by creating a Button component with different variants and sizes. We’re using Class Variance Authority (cva) to define the styles for each button and twMerge to handle merging the classes.

Button Component Setup

Here’s a Button component created using Shadcn:

import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';

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

const buttonVariants = cva(
  'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
  {
    variants: {
      variant: {
        default: 'bg-primary text-white hover:bg-primary/90',
        secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
        ghost: 'hover:bg-accent hover:text-accent-foreground',
        link: 'text-primary underline-offset-4 hover:underline',
      },
      size: {
        default: 'h-10 px-4 py-2 text-body',
        sm: 'h-9 rounded-md px-3',
        lg: 'h-11 rounded-md px-8 text-heading',
        icon: 'h-10 w-10',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'default',
    },
  }
);

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean;
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
    const Comp = asChild ? Slot : 'button';
    return (
      <Comp
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    );
  }
);
Button.displayName = 'Button';

export { Button, buttonVariants };

Merging Classes with twMerge

The following function merges Tailwind classes using twMerge:

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

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

Font Size Configuration

Notice that the default button size uses the custom class text-body, and the large size uses text-heading. These custom classes are defined in the Tailwind configuration file:

...
fontSize: {
  body: '1.2rem',
  heading: '1.8rem',
},
...

Testing the Button Component

If we test the button with different sizes:

<Button size="default">Primary Btn</Button>
<Button size="lg">Large Primary Btn</Button>

We’ll observe that while the font size is applied correctly, the text-white class for the default button variant does not work as expected.

...
        default: 'bg-primary text-white hover:bg-primary/90',

The button instead uses the default color set by the browser’s user agent.

image.png

Why Does This Happen?

tailwind-merge does not read your Tailwind config. It has its own built-in rules for which classes belong to which group.

It does not know that body is a font size, so it treats text-body as a text color. On top of that, cva outputs the variant classes before the size classes, so text-white comes first and text-body comes last.

Now both classes are in the “text color” group. tailwind-merge keeps only the last one in a group, so it removes text-white.

Built-in size names like text-lg or text-2xl do not cause this, because tailwind-merge already recognizes them as font sizes. The bug appears with semantic token names like body or heading, which most design systems use.

Resolving Class Conflicts with Custom Configuration

To fix this issue, we need to tell twMerge that body and heading are font sizes. Here’s how you can configure it:

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

const customTwMerge = extendTailwindMerge({
  extend: {
    classGroups: {
      'font-size': [{ text: ['body', 'heading'] }],
    },
  },
});

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

We use extend instead of overwriting the font-size group. It adds the custom tokens while keeping tailwind-merge’s default font sizes (text-sm, text-lg, etc.), so they still merge correctly.

image.png With this custom configuration, twMerge knows text-body is a font size, so it no longer removes the text-white class.

Using Tailwind v4?

In v4, font size tokens live in CSS under @theme (for example --text-body: 1.2rem), and you use tailwind-merge v3:

@theme {
  --text-body: 1.2rem;
  --text-heading: 1.8rem;
}

The bug is exactly the same, because tailwind-merge still doesn’t read your CSS. The fix is to register the tokens in the text theme scale:

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

const customTwMerge = extendTailwindMerge({
  extend: {
    theme: {
      text: ['body', 'heading'],
    },
  },
});

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

Every time you add a new --text-* token, add its name here too.

Conclusion

Now, if you test the buttons again, the text color will appear as intended, and twMerge will respect the text-white class.

Rule of thumb: every custom token you add to the Tailwind config (font sizes, colors, shadows) should also be registered in cn().

Summary

When using twMerge to merge Tailwind classes, conflicts can arise with custom configurations, such as font sizes or color classes. This occurs because twMerge uses predefined rules to resolve conflicting classes, which may not always work with custom setups. To fix this, you can use extendTailwindMerge to register your custom tokens, ensuring that styles like text-white are not unintentionally overridden.


Custom Tailwind configurations and design tokens are usually where the next class of subtle bugs lives. If your team is shipping a design system at scale, I take on React consulting engagements, including design-system implementation.

Interested in working together?

Open for senior frontend roles, contracting, and engineering consulting.