twMerge + classNames

twMerge + classNames

Tormod Haugland
Tormod Haugland
22 November 2024

Merger of class names in a good way

Many who create a new website use Tailwind as a framework for styling. One of the challenges everyone who has written Tailwind has experienced is having to merge classes dynamically. For example:

import { useState } from "react"

export function MyComponent() {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <div className={`bg-white p-4 ${isOpen ? "block" : "hidden"}`}>
      {/* ... */}
    </div>
  )
}

The challenge with this comes when you don't just have one variable to deal with, but many. Or when the classes to be used conditionally are more complex:

import { useState } from "react"

export function MyComponent() {
  const [isOpen, setIsOpen] = useState(false)
  const [highlightText, setHighlightText] = useState(false)

  return (
    <div
      className={`bg-white p-4 ${isOpen ? "block" : "hidden"} ${
        highlightText ? "text-yellow-500 leading-5" : ""
      }`}
    >
      {/* ... */}
    </div>
  )
}

And perhaps you want to retrieve class names from an overlying component?

import { useState } from "react"

interface MyComponentProps {
  className?: string
}

export function MyComponent({ className }: MyComponentProps) {
  const [isOpen, setIsOpen] = useState(false)
  const [highlightText, setHighlightText] = useState(false)

  return (
    <div
      className={`bg-white p-4 ${isOpen ? "block" : "hidden"} ${
        highlightText ? "text-yellow-500 leading-5" : ""
      } ${className ?? ""}`}
    >
      {/* ... */}
    </div>
  )
}

As you can see, the template literal needs spaces around each conditional segment. This ensures that the result is bg-white p-4 block text-yellow-500 leading-5 rather than bg-white p-4blocktext-yellow-500.

As the use becomes more complex, this can be difficult to keep track of. Especially if you have very long class names, which is common with Tailwind.

A solution to this is classnames the library. Or its lightweight little brother clsx. Let's start with clsx for the rest of the article.

We can rewrite the above code in two different ways with our new tool:

import clsx from "clsx"
import { useState } from "react"

export function MyComponent({ className }: { className?: string }) {
  const [isOpen, setIsOpen] = useState(false)
  const [highlightText, setHighlightText] = useState(false)

  return (
    <div
      className={clsx(
        "bg-white p-4",
        isOpen ? "block" : "hidden",
        highlightText && "text-yellow-500 leading-5",
        className,
      )}
    />
  )
}
import clsx from "clsx"
import { useState } from "react"

export function MyComponent({ className }: { className?: string }) {
  const [isOpen, setIsOpen] = useState(false)
  const [highlightText, setHighlightText] = useState(false)

  return (
    <div
      className={clsx(
        "bg-white p-4",
        { block: isOpen, hidden: !isOpen },
        { "text-yellow-500 leading-5": highlightText },
        className,
      )}
    />
  )
}

clsx (and classnames) automatically fixes spaces for us, and is very flexible on the input we send in. It is e.g. no problem if "classNames" is undefined.

So then everything is good, right?

Problems merging conflicting class names

A challenge arises when we try to merge classes that provide similar instructions:

import clsx from "clsx"

export function MyComponent({ className }: { className?: string }) {
  return <div className={clsx("bg-white p-4 text-black", className)} />
}

export function ParentComponent() {
  return <MyComponent className="p-6" />
}

What padding do you expect on MyComponent? The resulting class name is bg-white p-4 text-black p-6, so it is natural to expect p-6 (padding: 1.5rem) to win.

No. The way HTML and CSS work will p-4 be the dominant padding, and our overriding of the default value will not work.

Obvious solutions to this problem quickly fall short when dealing with more complicated cases such as p-4 px-2 pb-3 p-5 py-3 .

Fortunately, there is a library for this: tailwind-merge. Tailwind-merge is similar to classNames and clsx in the way it accepts input:

twMerge('px-2 py-1 bg-red hover:bg-dark-red', 'p-3 bg-[#B91C1C]')
// → 'hover:bg-dark-red p-3 bg-[#B91C1C]'

The challenge with tailwind-merge, however, is that it is less flexible about the input it accepts. For example, it does not handle JavaScript objects like the one used above:

twMerge("bg-white p-4 hidden text-black",
  {
    "block": isOpen,
    "text-yellow-500 leading-5": highlightText
  },
  classNames
)
// → 💥

The Solution So what is the solution to both of our problems? Yes, merging twMerge and clsx:

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

export function cn(...classes: ClassValue[]): string {
  return twMerge(clsx(...classes))
}

Now if we change our component to:

import { useState } from "react"
import { cn } from "~/utils/cn"

export function MyComponent({ className }: { className?: string }) {
  const [isOpen, setIsOpen] = useState(false)
  const [highlightText, setHighlightText] = useState(false)

  return (
    <div
      className={cn(
        "bg-white p-4 text-black",
        { block: isOpen, hidden: !isOpen },
        { "text-yellow-500 leading-5": highlightText },
        className,
      )}
    />
  )
}

export function ParentComponent() {
  return <MyComponent className="p-6" />
}

Now the component is displayed with p-6 padding, and all class names are merged correctly.

Inspiration taken here and here.

Build the future of your product with zero headaches

From MVP prototypes to scalable platforms, our full-stack dev team turns your roadmap into rock-solid code. Get to market faster without sacrificing quality.

Get started

Related articles