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.