We all love Tailwind CSS. That utility-first CSS framework that has gained significant traction in web development for its flexible, highly customizable approach to styling applications. By offering a vast array of utility classes, Tailwind enables direct in-markup design customization, fostering rapid prototyping and streamlined workflows. This leads to smaller CSS files and a more integrated development experience, avoiding the frequent back-and-forth between HTML and CSS files.
However, dynamically building classes can produce unwanted gotchas. Mismanagement can result in increased bundle sizes, which affects application performance. Additionally, class conflicts may occur, leading to styles not being applied as intended. This technique can also complicate the codebase, making it challenging to manage conditional classes directly within components. Here is where utilizing efficient tools like clsx
and tailwind-merge
can be of great benefit.
The Code
Let’s create a simple button component that takes in a couple parameters. A primary boolean to highlight our call to action and a disabled state.
type ButtonProps = {
primary: boolean;
disabled?: boolean;
}
export const Button: React.FC<ButtonProps> = ({ primary, disabled }) => {
let buttonClass = 'px-4 py-2 rounded transition-colors ';
if (primary) {
buttonClass += 'bg-blue-500 hover:bg-blue-600 text-white';
} else if (!primary && !disabled) {
buttonClass += 'bg-gray-300 hover:bg-gray-400 text-gray-600';
}
if (disabled) {
buttonClass = 'px-4 py-2 rounded transition-colors bg-gray-200 text-gray-400 pointer-events-none';
}
return <button className={buttonClass} disabled={disabled}>Click me</button>;
};
This works but using string concatenation for class names like this can quickly become cumbersome and error-prone as the complexity of your component grows. We have some redundant code too as we don’t want to have class conflicts if the button is primary and disabled.
This is where a tool like clsx
can help (the classnames
library is another popular option). clsx
is a utility library used for conditionally joining class names together in a more manageable and readable way. It enables developers to dynamically construct class strings based on the truthiness of conditions. Here is how we can update our component using that library. Let’s use it to clean up our conditional statements.
import clsx from 'clsx';
type ButtonProps = {
primary: boolean;
disabled?: boolean;
}
export const Button: React.FC<ButtonProps> = ({ primary, disabled }) => {
const buttonClass = clsx(
'px-4 py-2 rounded transition-colors',
{
'bg-blue-500 hover:bg-blue-600 text-white': primary,
'bg-gray-300 hover:bg-gray-400 text-gray-600': !primary && !disabled,
'bg-gray-200 text-gray-400 pointer-events-none': disabled
}
);
return <button className={buttonClass} disabled={disabled}>Click me</button>;
};
This approach alone allows us to simplify the conditions and readability of the code. There is a bit of an issue still though. The library is conditionally concatenating the class strings for us but there is a scenario where we are going to have unwanted results. If both primary
and disabled
are set to true
, we are going to receive an output like this:
px-4 py-2 rounded transition-colors bg-blue-500 hover:bg-blue-600 text-white bg-gray-200 text-gray-400 pointer-events-none
Because we are just concatenating the strings, we are getting conflicting background and text color styles in our class string. And though it will preform as expected since the CSS will be built in the order of the classes, they will all be added causing unnecessary bloat. This can be solved by adding && !disabled
to the truthy primary condition. Fine, problem solved for this instance but there is a better solution.
Enter tailwind-merge
. It is a utility designed to efficiently merge Tailwind CSS class names, automatically handling conflicts between overlapping styles and ensuring the most specific styles prevail. This will simplify the management of dynamic class combinations, making it easier to apply conditional styling without redundancy or potential errors.
Let’s integrate it into our code by passing our buttonClasses
variable to it.
import clsx from 'clsx';
import twMerge from 'tailwind-merge';
type ButtonProps = {
primary: boolean;
disabled?: boolean;
}
const Button: React.FC<ButtonProps> = ({ primary, disabled }) => {
const buttonClass = clsx(
'px-4 py-2 rounded transition-colors',
{
'bg-blue-500 hover:bg-blue-600 text-white': primary,
'bg-gray-300 text-gray-600': !primary && !disabled,
'bg-gray-200 text-gray-400 pointer-events-none': disabled
}
);
return <button className={**twMerge**(buttonClass)} disabled={disabled}>Click me</button>;
};
Now when both primary
and disabled
are true
, tailwind-merge
will trim out the bg-blue-500
and text-white
that are being overwritten by the disabled classes.
In many situations, we would want this component used in many situations where the developer may need to pass in styles to satisfy their particular use case. This is where the libraries really shine.
Let’s update our component to accept styles passed in.
import clsx from 'clsx';
import twMerge from 'tailwind-merge';
type ButtonProps = {
primary: boolean;
disabled?: boolean;
} **& React.HTMLAttributes<HTMLElement>**;
const ButtonCLSXTWM: React.FC<ButtonProps> = ({
primary,
disabled,
**...props**
}) => {
const buttonClass = clsx(
'px-4 py-2 rounded transition-colors',
{
'bg-blue-500 hover:bg-blue-600 text-white': primary,
'bg-gray-300 hover:bg-gray-400 text-gray-600':
!primary && !disabled,
'bg-gray-200 text-gray-400 pointer-events-none': disabled,
},
**props.className**
);
return (
<button className={twMerge(buttonClass)} disabled={disabled}>
Click me
</button>
);
};
Now developers can override colors, padding or roundness and they will override the pre-defined classes cleanly.
Let’s make the button red.
<Button primary className="bg-red-500 hover:bg-red-600" />
Tying Up Loose Ends on Optimizing Dynamic Class Management
This solution would be best suited as a helper utility to be added into components. Lets see what that would look like.
💡 Make it a utility function to be used throughout the app
// utils.ts
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// Button.tsx
import { cn } from 'utils';
type ButtonProps = {
primary: boolean;
disabled?: boolean;
} & React.HTMLAttributes<HTMLElement>;
export const ButtonCLSXTWM: React.FC<ButtonProps> = ({
primary,
disabled,
...props
}) => {
const buttonClass = cn(
'px-4 py-2 rounded transition-colors',
{
'bg-blue-500 hover:bg-blue-600 text-white': primary,
'bg-gray-300 hover:bg-gray-400 text-gray-600':
!primary && !disabled,
'bg-gray-200 text-gray-400 pointer-events-none': disabled,
},
props.className
);
return (
<button className={buttonClass} disabled={disabled}>
Click me
</button>
);
};
// Header.tsx
import { cn } from './utils';
type HeaderProps = {
title: string;
} & React.HTMLAttributes<HTMLElement>;
export const Header: React.FC<HeaderProps> = ({ title, className }) => {
return (
<header>
<h1
className={cn(
'text-xl font-semibold bg-gray-100 text-gray-800',
className
)}
>
{title}
</h1>
</header>
);
};
And just like that we get the benefits of the libraries!
I prefer to keep the function name small like cn
(class name) so that it can do it’s thing and the developer can focus on the code that matters for the component.