Introduction
In modern web development, creating reusable and customizable components is essential. This often involves needing differently styled components that share the same functionality. In this post different approaches are outlined to create different component variants. We will be using React with TypeScript and Tailwind CSS, but the principles apply to any React project.
Approaches
Let's start with the example everyone is probably familiar with: a button! The following Button
component serves as our starting point:
import React from "react";
import { ComponentPropsWithRef } from "react";
type ButtonProps = ComponentPropsWithRef<"button">;
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
return <button ref={ref} className="p-2 bg-green" {...props} />;
});
export { Button };
Method 1: CSS Class Overwrites
This button is very simple and all it really does is forwarding the ref and applying a green
background with some padding. This button works perfectly as a save button for example. Now, for our
new feature we have to add a red delete button. The easiest and quickest method of achieving our
goal would be to extend our Button
component to allow className
overwrites:
import React from "react";
import { ComponentPropsWithRef } from "react";
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
type ButtonProps = ComponentPropsWithRef<"button">;
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({ className, ...props }, ref) => {
return <button ref={ref} className={cn("p-2 bg-green", className)} {...props} />;
});
export { Button };
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
The cn()
function is a wrapper around two utility functions which are both incredibly useful:
clsx
and twMerge
. The former ensures that we can apply classes conditionally much more easily.
For intance, we can call cn('p-2', someCondition && 'p-1')
or cn('p-2', {'p-1': someCondition})
.
The latter ensures that the resulting classes make sense when using Tailwind CSS. If someCondition
evaluates to true
, without twMerge
, the output would be 'p-2 p-1'
. This is problematic due to
the way the browser applies CSS styles: styles are applied based on the order in which they appear
in the stylesheet. Thus, it is not guaranteed that the 'p-1' styles overwrite the 'p-2' styles (even
though this is what we wanted). This happens because p-2
appears after p-1
in the stylesheet
generated by Tailwind CSS. twMerge
ensures that styles which are passed in later are indeed the ones
being applied by removing 'p-2'
in our example, resulting in only 'p-1'
.
Now, to achieve our goal of a button with a red background:
<Button className="bg-red">Delete</Button>
This works great if what you need is a one-off variant of an existing component, and it is a very
powerful pattern to apply layouting styles that we do not want inside a reusable component, e.g. a
margin-top
or similar.
However, this red button in our case is part of our design system and we will use it accross our whole application. It would be a shame if we had to change the background color in all the places we end up using this red button, should we decide on a darker red color.
Method 2: Seperate Component
One could be tempted to create a seperate component for each variant, e.g. SuccessButton
and
DestructiveButton
. However, this comes with some downsides:
First of all, the variants no longer automatically share the same functionality and base styling. Of course, we can fix that by creating a shared button which is used by all the variants:
import React from "react";
import { ComponentPropsWithRef } from "react";
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
type ButtonProps = ComponentPropsWithRef<"button">;
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({ className, ...props }, ref) => {
return <button ref={ref} className={cn("p-2", className)} {...props} />;
});
const SuccessButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, ...props }, ref) => {
return <Button ref={ref} className={cn("bg-green", className)} {...props} />;
},
);
const DestructiveButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, ...props }, ref) => {
return <Button ref={ref} className={cn("bg-red", className)} {...props} />;
},
);
export { SuccessButton, DestructiveButton };
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Looks okay, but there is one huge downside: it does not scale well! It is common for buttons to have
different sizes. Creating a new size variant would result in annoying long names like
SmallSuccessButton
or LargeDestructiveButton
and the number of components would grow
exponentially with each newly added variant.
Also, this approach has another downside: it's a different component. This might not sound like a problem, until you consider how React works. When React rerenders it compares its updated internal render tree to the previous one. If a node in this tree changes, then it will be removed from the DOM and replaced by the new one. It's important to note that not the DOM structure is relevant, but really Reacts internal tree representation. This means that if we have the following code:
const RandomButton = () => {
const someCondition = Math.random() < 0.5;
const children = "Click me!";
return someCondition ? (
<SuccessButton children={children} />
) : (
<DestructiveButton children={children} />
);
};
Each time this button is rerendered and someCondition
evaluates to a new value, the old button
component is umounted and the new one is mounted. This would become appearent and annoying if we had
animations on our buttons. Another related downside is that all internal state of those buttons
would be lost when someCondition
changes because each button is treated as a seperate instance.
This is not really a problem with a button, but imagine an accordion or an input.
There must be a better option, right?
Method 3: Using Props
If we just update the props of a component, it remains in the DOM and keeps all its state. Great! A first attempt might look like this:
import React from "react";
import { ComponentPropsWithRef } from "react";
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
type ButtonProps = ComponentPropsWithRef<"button"> & {
intent: "success" | "destructive";
};
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, intent, ...props }, ref) => {
return (
<button
ref={ref}
className={cn("p-2 bg-green", { "bg-red": intent === "destructive" }, className)}
{...props}
/>
);
},
);
export { Button };
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Following the previous example, chances are we would want a size
variant at some point. The more
sizes and intents we have, the larger and harder to maintain becomes our button component. Wouldn't
it be nice if we could abstract the creation of all those variants away?
Introducing Class Variance Authority
This is where Create Variance Authority (or short cva
) comes into play. It
offers a very simple, yet powerful API (it's literally just one function) to achieve our goal.
Our button can be rewritten (including our new size
variant) as follows:
import React from "react";
import { ComponentPropsWithRef } from "react";
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { cva, type VariantProps } from "class-variance-authority";
const buttonVariants = cva("text-md", {
variants: {
intent: {
success: "bg-green",
destructive: "bg-red",
},
size: {
small: "text-sm p-1",
large: "text-lg p-3",
},
},
defaultVariants: {
intent: "success",
size: "large",
},
});
type ButtonProps = ComponentPropsWithRef<"button"> & VariantProps<typeof buttonVariants>;
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, intent, size, ...props }, ref) => {
return (
<button ref={ref} className={cn(buttonVariants({ intent, size }), className)} {...props} />
);
},
);
export { Button };
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// Example usage
<Button intent="destructive" size="small">
Delete
</Button>;
As you can see, cva
allows us to create component variants by simply passing a configuration object
into the cva
function. We get typesafety inside the object, meaning if we decide to rename
our variant, TypeScript will complain if we do not also rename the keys inside the cva
function
call.
Composition
What I really like about the fact that the variants are a simple function call, making them easy to reuse. Let's assume we would like to style an anchor tag (or any other element for that matter). We can just write without having to write any conditions ourselves:
<a href="" className={buttonVariants({ intent: "destructive", size: "large" })}>
Delete
</a>
Or we could compose other variants from those variants, creating our own abstractions where it makes sense.
Compound Variants
Another advantage of using cva
is how easy it is to create compound variants. If we wanted to apply
certain styles only when the button is both destructive
and large
, we can simply extend our cva
method call:
const buttonVariants = cva("text-md", {
variants: {
intent: {
success: "bg-green",
destructive: "bg-red",
},
size: {
small: "text-sm p-1",
large: "text-lg p-3",
},
},
defaultVariants: {
intent: "success",
size: "large",
},
compoundVariants: [
{
intent: "destructive",
size: "large",
className: "uppercase font-bold",
},
],
});
Conclusion
Each of the methods discussed has its own merits and use cases. The CSS class overwrite method is
perfect for one-off variants where quick customizations are needed. Creating separate components for
each variant can work but becomes cumbersome and less maintainable as the number of variants grows.
However, the clear winner is the cva
approach. It offers a powerful, flexible, and type-safe way
to manage component variants, making it easier to maintain and extend our design system. It further
allows for the composition of variants and the creation of compound variants, giving developers a
great tool to handle complex styling needs in scalable way.