Building maintainable and extensible components with TailwindCSS

In this article we are going to build a component named Button with its modifiers such as variant and size.

The folling code is the base of our Button component

type ButtonProps = {
  children: React.ReactNode;
};
 
const Button = ({ children }: ButtonProps) => {
  return <button>{children}</button>;
};
 
export { Button };

Now we want to create the following styles of button: primary, secondary and text. Button component would need receive by props which kind of button is, in order to apply the styles linked to that type. TailwindCSS don’t allow dynamic styles so we will need to use something else. In this case we could use an object of key=style and property=classes

type ButtonProps = {
  children: React.ReactNode;
  variant: string;
};
 
const variants = {
  primary:
    "bg-blue-500 px-5 text-white text-base font-normal tracking-wide py-2 rounded-full",
  text: "px-5 text-blue-500 text-base font-normal tracking-wide py-2 rounded-full",
};
 
const Button = ({ children, variant }: ButtonProps) => {
  return <button className={variants[variant]}>{children}</button>;
};
 
export { Button };

The idea of the above code is not bad, it works ! But the developer experience is terrible we don’t have hints of which are the variant available. It just says: it receives strings

We could swap it to

type ButtonProps = {
  children: React.ReactNode;
  variant: "primary" | "secondary";
};

Now we have the hints

But now we need to maintain two structures: the type ButtonProps and the object variants if we want to add more variants. At this point Typescript comes to the rescue: key of type

we going to create the available types from the object variant

type ButtonVariants = keyof typeof variants;

Adding the property sizes and default values the code would be:

type ButtonVariants = keyof typeof variants;
type ButtonSizes = keyof typeof sizes;
 
type ButtonProps = {
  children: React.ReactNode;
  variant?: ButtonVariants;
  size?: ButtonSizes;
};
 
const variants = {
  primary:
    "bg-blue-600 px-5 text-white font-normal tracking-wide py-2 rounded-full",
  text: "px-5 text-blue-600 font-normal tracking-wide py-2 rounded-full",
};
 
const sizes = {
  small: "text-xs",
  normal: "text-base",
  large: "text-lg",
};
 
const Button = ({
  children,
  variant = "text",
  size = "normal",
}: ButtonProps) => {
  return (
    <button className={`${variants[variant]} + ${sizes[size]}`}>
      {children}
    </button>
  );
};

it is almost done, but if we see the type ButtonProps it is missing onClick, disable, etc. For that we will add to the type the default props of a Button:

type ButtonProps = {
  children: React.ReactNode;
  variant: ButtonVariants;
  size: ButtonSizes;
} & ButtonHTMLAttributes<HTMLButtonElement>;

Now we just add the other properties using the spread operator in the prop rest

const Button = ({ children, variant, size, ...rest }: ButtonProps) => {
  return (
    <button className={`${variants[variant]} + ${sizes[size]}`} {...rest}>
      {children}
    </button>
  );
};

At this point all the properties of a Button are available, included className. If the user sets className prop it will overwrite the className from our Buttton. We will need to exclude it from its props. For achieving this we could use the utility type Omit from Typescriptt as follow:

type ButtonProps = {
  children: React.ReactNode;
  variant?: ButtonVariants;
  size?: ButtonSizes;
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "className">;

This Button Component is pretty good. But it lacks of autocomplete feature when we want to edit the classes of our objects variants or sizes.

We could use a tiny library called Class Variance Authority to:

So the Button component would look like this:

import { ButtonHTMLAttributes, FC } from "react";
import { cva, type VariantProps } from "class-variance-authority";
 
const button = cva(["font-normal rounded-full px-5 py-2 tracking-wide"], {
  variants: {
    variant: {
      primary: "bg-blue-600 text-white",
      text: "text-blue-600",
    },
    size: {
      small: "text-xs",
      medium: "text-base",
      large: "text-lg",
    },
  },
  defaultVariants: {
    variant: "text",
    size: "medium",
  },
});
 
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
  VariantProps<typeof button>;
 
export const Button: FC<ButtonProps> = ({ variant, size, ...props }) => {
  return <button className={button({ variant, size })} {...props} />;
};