Creating Component Variants in React

Creating Component Variants in React

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.