Like any other package, tailwind-merge comes with opportunities and trade-offs. This document tries to help you decide whether tailwind-merge is the right tool for your use case based on my own experience and the feedback I got from the community.
Note If you're thinking of a major argument that is not covered here, please let me know!
Generally speaking, there are situations where you could use tailwind-merge but probably shouldn't. Think of tailwind-merge as an escape hatch rather than the primary tool to handle style variants.1
tailwind-merge relies on a large config (~5 kB out of the ~7 kB minified and gzipped bundle size) to understand which classes are conflicting. This might be limiting if you have tight bundle size constraints.
With large teams or components that are made available publicly you can expect users of components to use and misuse the component's API in any way the component allows. With this in mind tailwind-merge might give too much freedom to users of a component which could make it harder to maintain and evolve the component over time. With tailwind-merge you give up full control over styling in your components.
When you allow arbitrary classes to be passed into a component, you can break the styles of the component's users when you refactor the component's internal styles. If you need to be able to refactor a component's styles often, those styles shouldn't be merged with styles from props unless you're willing to refactor the component's uses as well.
tailwind-merge is probably only useful if you use Tailwind CSS and compose components together in some form. If you have a use case for tailwind-merge outside of those boundaries, please let me know, I'm curious about it!
tailwind-merge is a great fit for highly composed components, like in design systems or UI component libraries. If you expect that styles of a component will be modified on multiple levels, e.g. ContextMenuOption → MenuOption → BaseOption, with each component passing some modifications to the component it renders, tailwind-merge can help you to keep the API surface between components small.
tailwind-merge allows you to support a wide range of styling use cases without having to explicitly define each of them separately within a component. E.g. you can pass a custom width to a button component, change its text color or position it absolutely with a single className
prop without the need to define support for custom widths, text colors or positioning within the button component explicitly.
Let's say you have a Button component that you already use in many places. You have a place in your app in which you want to make its background red to signal that the action of the button is destructive. You could modify the Button component to deal with the concept of destructiveness (e.g. by passing a variant
prop with the value destructive
), but then you'd need to make sure that those styles work with all the other permutations of the component which you don't need in the place where the destructive button is used. And maybe you're not even sure whether you'll keep the Button red in this one place, so the time investment of making the Button understand destructiveness doesn't seem worth it.
tailwind-merge allows you to defer the creation of abstractions like destructiveness to the point where you're sure that you need them. You can just pass a className
prop to the Button component in which you define the red background and be done with it for now. If you later decide that you want to make the Button red in more places, you can still define the logic inside the Button component later.
If you want to merge classes that are all defined within a component, prefer using the twJoin
function over twMerge
. As the name suggests, twJoin
only joins the class strings together and doesn't deal with conflicting classes.
// React components with JSX syntax used in this example
import { twJoin } from 'tailwind-merge'
function MyComponent({ forceHover, disabled, isMuted }) {
return (
<div
className={twJoin(
TYPOGRAPHY_STYLES_LABEL_SMALL,
'grid w-max gap-2',
forceHover ? 'bg-gray-200' : ['bg-white', !disabled && 'hover:bg-gray-200'],
isMuted && 'text-gray-600',
)}
>
{/* More code… */}
</div>
)
}
Joining classes instead of merging forces you to write your code in a way so that no merge conflicts appear which seems like more work at first. But it has two big advantages:
-
It's much more performant because no conflict resolution is computed.
twJoin
has the same performance characteristics as other class joining libraries likeclsx
. -
It's usually easier to reason about. When you can't override classes, you naturally start to put classes that are in conflict with each other closer together through conditionals like ternaries. Also when a condition within the
twJoin
call is truthy, you can be sure that this class will be applied without the need to check whether conflicting classes appear in a later argument. Not relying on overrides makes it easier to understand which classes are in conflict with each other and which classes are applied in which cases.
But there are also exceptions to (2) in which using twMerge
for purely internally defined classes is preferable, especially in some complicated cases. So just take this as a rule of thumb.
The primary purpose of tailwind-merge is to merge a className
prop with the default classes of a component.
// React components with JSX syntax used in this example
import { twMerge } from 'tailwind-merge'
function MyComponent({ forceHover, disabled, isMuted, className }) {
return (
<div
className={twMerge(
TYPOGRAPHY_STYLES_LABEL_SMALL,
'grid w-max gap-2',
forceHover ? 'bg-gray-200' : ['bg-white', !disabled && 'hover:bg-gray-200'],
isMuted && 'text-gray-600',
className,
)}
>
{/* More code… */}
</div>
)
}
You don't need to worry about potentially expensive re-renders here because tailwind-merge caches results so that a re-render with the same props and state becomes computationally lightweight as far as the call to twMerge
goes.
If you use a custom Tailwind CSS config, don't forget to configure tailwind-merge as well.
In case the disadvantages of tailwind-merge weigh in too much for your use case, here are some alternatives that might be a better fit.
This is the good-old way of styling components and is also probably your default. E.g. think of a variant prop that toggles between primary and secondary styles of a button. The variant
prop is already toggling between internal styles of the component and you can use the same pattern to define any number of styling use cases to a component. If you have a one-off use case to give the button a full width, you can add a isFullWidth
prop to the button component which toggles the w-full
class internally.
// React components with JSX syntax used in this example
function Button({ variant = 'primary', isFullWidth, ...props }) {
return <button {...props} className={join(BUTTON_VARIANTS[variant], isFullWidth && 'w-full')} />
}
const BUTTON_VARIANTS = {
primary: 'bg-blue-500 text-white',
secondary: 'bg-gray-200 text-black',
}
function join(...args) {
return args.filter(Boolean).join(' ')
}
If you have too many different one-off use cases to add a prop for each of them to a component, you can use Tailwind's important modifier to override internal styles.
// React components with JSX syntax used in this example
function MyComponent() {
return (
<>
<Button className="w-full">No danger</Button>
<Button className="w-full !bg-red-500" >Danger!</Button>
</>
)
}
function Button({ className ...props }) {
return <button {...props} className={join('bg-blue-500 text-white', className)} />
}
function join(...args) {
return args.filter(Boolean).join(' ')
}
The main downside of this approach is that it only works one level deep (you can't override the !bg-red-500
class in the example above). But if you don't need to be able to override styles through multiple levels of composition, this might be the most lightweight approach possible.
Next: Features
Previous: What is it for
Footnotes
-
Don't just take my word for it, Simon Vrachliotis thinks so too. ↩