Button variants and TailwindCSS

Button variants and TailwindCSS

It's only how I implement this in my projects. You can do what you want for your UI components as long as it suits your need and your team.

Packages I use

To design with tailwindcss I use three different packages, clsx to conditionnaly construct my classes, class-variance-authority a package that allow you to create an objet with a base style, variants, and compound variants, and tailwind-merge a utility function to efficiently merge tailwindcss classes without style conflicts.

npm install class-variance-authority clsx tailwind-merge

Once I've this packages in my project with tailwind. I create a util function cn that is just a shortcut to use tailwind-merge and clsx directly together.

import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
 
export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

Button variants

For me a button is very simple, it has a base style that manage roundness, font, and size of the button. Then there is only color that is applied on border, background and text for each state of the button.

Now that you know how I interpret a button. Let's create a base with cva from class-variance-authority. Even in Button should be simple there is a lot of different design that can be applied in a project so for this I prefer to use Class Variance Authority.

This is the base style I use on this blog:

import { cva } from "class-variance-authority";
 
export const baseButtonVariants = cva(
  "font-medium text-center relative whitespace-nowrap align-middle outline-none inline-flex items-center justify-center",
  {
    variants: {
      size: {
        xs: "text-xs h-6 min-w-6 px-2",
        sm: "text-sm h-8 min-w-8 px-3",
        md: "text-base h-10 min-w-10 px-4",
        lg: "text-lg h-12 min-w-12 px-6",
        xl: "text-xl h-14 min-w-14 px-8",
      },
      rounded: {
        none: "rounded-none",
        md: "rounded-md",
        full: "rounded-full",
      },
    },
    defaultVariants: {
      size: "md",
      rounded: "md",
    },
  },
);

Here is an example only with a background color to display variants of size and roundness

Base Style Buttons

Then I create a buttonVariants that is also use cva. In this buttonVariants their will be all kind of variants your button can have, for example: default, brand, destructive, etc. But for me it's mandatory to have different kinds of variants such as solid, outline, subtle or ghost.

Because cva just return a function with parameters I decided to create subvariants like solidVariants and use it directly in buttonVariants. At the end with this approach I have a code like this one below.

import { cva } from "class-variance-authority";
 
const solidVariants = cva("", {
  variants: {
    variant: {
      default:
        "bg-zinc-900 text-white hover:bg-zinc-700 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-200",
      brand:
        "outline-none bg-gradient-to-r from-brand-600 via-brand-400 to-brand-600 text-white hover:outline-2 outline-offset-0 hover:outline-zinc-900 transition-all bg-left hover:bg-right bg-[size:200%]",
      destructive:
        "bg-red-500 text-white hover:bg-red-600 dark:hover:bg-red-600",
    },
  },
});
 
export const buttonVariants = cva(
  "focus:ring-2 focus:ring-zinc-300 focus:ring-offset-2 disabled:opacity-50 dark:focus:ring-zinc-600 disabled:pointer-events-none dark:focus:ring-offset-zinc-900 transition-all duration-150",
  {
    variants: {
      variant: {
        default: solidVariants({ variant: "default" }),
        brand: solidVariants({ variant: "brand" }),
        destructive: solidVariants({ variant: "destructive" }),
        link: "bg-transparent underline-offset-4 hover:underline text-zinc-900 dark:text-zinc-100 hover:bg-transparent dark:hover:bg-transparent",
        unstyled: "",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  },
);

As you can see for me solid is the default variant for a button, and with that code now we can do this with our baseButtonVariants

Solid Style Buttons

Now I can add other variants like ghost, outline and subtle the same way I added solid. Except now other than default variant of each types will have a prefix {{type}}-.

import { cva } from "class-variance-authority";
 
const outlineVariants = cva("border-2", {
  variants: {
    variant: {
      default:
        "text-zinc-900 hover:bg-zinc-200 border-zinc-900 dark:text-white dark:hover:bg-zinc-200 dark:border-white dark:hover:bg-white/20",
      brand: "text-brand-500 hover:bg-brand-400/20 border-brand-500",
      destructive: "text-red-500 hover:bg-red-400/20 border-red-500",
    },
  },
});
 
const subtleVariants = cva("", {
  variants: {
    variant: {
      default:
        "text-zinc-900 bg-zinc-200 hover:bg-zinc-300 border-zinc-900 dark:text-white dark:hover:bg-zinc-200 dark:border-white dark:bg-white/20 dark:hover:bg-white/30",
      brand:
        "text-brand-500 bg-brand-400/20 hover:bg-brand-400/30 border-brand-500",
      destructive:
        "text-red-500 bg-red-400/20 hover:bg-red-400/30 border-red-500",
    },
  },
});
 
const ghostVariants = cva("", {
  variants: {
    variant: {
      default:
        "text-zinc-900 hover:bg-zinc-200 border-zinc-900 dark:text-white dark:hover:bg-zinc-200 dark:border-white dark:hover:bg-white/20",
      brand: "text-brand-500 hover:bg-brand-400/20 border-brand-500",
      destructive: "text-red-500 hover:bg-red-400/20 border-red-500",
    },
  },
});
 
export const buttonVariants = cva("...", {
  variants: {
    variant: {
      //...
      outline: outlineVariants({ variant: "default" }),
      "outline-brand": outlineVariants({ variant: "brand" }),
      "outline-destructive": outlineVariants({ variant: "destructive" }),
      ghost: ghostVariants({ variant: "default" }),
      "ghost-brand": ghostVariants({ variant: "brand" }),
      "ghost-destructive": ghostVariants({ variant: "destructive" }),
      subtle: subtleVariants({ variant: "default" }),
      "subtle-brand": subtleVariants({ variant: "brand" }),
      "subtle-destructive": subtleVariants({ variant: "destructive" }),
      //...
    },
  },
});

All button variants

How I use this with react

Now that I have my variants for my buttons and my base style. I want to have a component that handle isLoading, leftIcon, rightIcon, and an IconButton

For that I create a BaseButton component that only has the baseButtonVariants style.

export type BaseButtonProps = React.ComponentPropsWithoutRef<"button"> &
  VariantProps<typeof baseButtonVariants> & {
    isLoading?: boolean;
    leftIcon?: React.ReactElement;
    rightIcon?: React.ReactElement;
  };
 
export const BaseButton = React.forwardRef<
  React.ElementRef<"button">,
  BaseButtonProps
>(
  (
    {
      className,
      size,
      rounded,
      leftIcon,
      rightIcon,
      children,
      isLoading,
      disabled,
      ...props
    },
    ref,
  ) => {
    const { icon, iconPlacement } = useMemo(() => {
      let icon = rightIcon ? rightIcon : leftIcon;
 
      if (isLoading) {
        icon = <TbLoader className="animate-spin-slow" />;
      }
 
      return {
        icon,
        iconPlacement: rightIcon ? ("right" as const) : ("left" as const),
      };
    }, [isLoading, leftIcon, rightIcon]);
 
    return (
      <button //Most of the time I use a polymorphic component that change to a link when there is href in props
        className={cn(baseButtonVariants({ size, rounded, className }))}
        ref={ref}
        disabled={disabled || isLoading}
        data-state={isLoading ? "loading" : undefined}
        {...props}
      >
        {icon && iconPlacement === "left" ? (
          <span
            className={cn(
              { "mr-2": !!children },
              "inline-flex shrink-0 self-center",
            )}
          >
            {icon}
          </span>
        ) : null}
        {children}
        {icon && iconPlacement === "right" ? (
          <span
            className={cn(
              { "ml-2": !!children },
              "inline-flex shrink-0 self-center",
            )}
          >
            {icon}
          </span>
        ) : null}
      </button>
    );
  },
);
 
BaseButton.displayName = "BaseButton";

Then I create a very simple Button component but now that has also buttonVariants as props and className.

export const Button = React.forwardRef<
  React.ElementRef<typeof BaseButton>,
  React.ComponentPropsWithoutRef<typeof BaseButton> &
    VariantProps<typeof buttonVariants>
>(({ className, variant, ...props }, ref) => (
  <BaseButton
    className={cn(buttonVariants({ variant, className }))}
    {...props}
    ref={ref}
  />
));
 
Button.displayName = "Button";

With this Button component I can now create a lot of different Buttons just like below.

React Buttons

Now because in the base variant I use a min-w-[x] with the same size as the h-[x], I can create an icon button just by removing horizontal padding with p-0. So my IconButton in react looks like this. I remove as well leftIcon and rightIcon

export const IconButton = React.forwardRef<
  React.ElementRef<typeof Button>,
  Omit<React.ComponentPropsWithoutRef<typeof Button>, "leftIcon" | "rightIcon">
>(({ className, children, ...props }, ref) => (
  <Button {...props} className={cn("p-0", className)} ref={ref}>
    {props.isLoading ? null : children}
  </Button>
));
 
IconButton.displayName = "IconButton";

React IconButtons

Any feedback ?

If there is any problem of comprehension, typo or formulation, do not hesitate to reach me on Twitter. I'll fix the problem as soon as possible. My english is probably not very good yet, but what computer science taught me is that we can't learn without practice.

Share

Vincent Dusautoir

Vincent Dusautoir

Whether you're looking for an expert for a cool digital project - or have a full-time job opportunity, you can easily reach me by email.

Available for work