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.

    pnpm 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

    import { baseButtonVariants } from './base';
    import { cn } from '#/utils/tailwind';
    
    export function ButtonsUnstyled() {
      return (
        <div className="flex flex-col items-center justify-center gap-3">
          <div className="flex items-end gap-2">
            <button
              className={cn(baseButtonVariants({ size: 'xs' }), 'bg-cyan-400')}
            >
              Button xs
            </button>
            <button
              className={cn(baseButtonVariants({ size: 'sm' }), 'bg-cyan-400')}
            >
              Button sm
            </button>
            <button
              className={cn(baseButtonVariants({ size: 'md' }), 'bg-cyan-400')}
            >
              Button md
            </button>
            <button
              className={cn(baseButtonVariants({ size: 'lg' }), 'bg-cyan-400')}
            >
              Button lg
            </button>
            <button
              className={cn(baseButtonVariants({ size: 'xl' }), 'bg-cyan-400')}
            >
              Button xl
            </button>
          </div>
    
          <div className="flex items-end gap-2">
            <button
              className={cn(
                baseButtonVariants({ size: 'xs', rounded: 'none' }),
                'bg-cyan-400'
              )}
            >
              Button xs
            </button>
            <button
              className={cn(
                baseButtonVariants({ size: 'sm', rounded: 'none' }),
                'bg-cyan-400'
              )}
            >
              Button sm
            </button>
            <button
              className={cn(
                baseButtonVariants({ size: 'md', rounded: 'none' }),
                'bg-cyan-400'
              )}
            >
              Button md
            </button>
            <button
              className={cn(
                baseButtonVariants({ size: 'lg', rounded: 'none' }),
                'bg-cyan-400'
              )}
            >
              Button lg
            </button>
            <button
              className={cn(
                baseButtonVariants({ size: 'xl', rounded: 'none' }),
                'bg-cyan-400'
              )}
            >
              Button xl
            </button>
          </div>
    
          <div className="flex items-end gap-2">
            <button
              className={cn(
                baseButtonVariants({ size: 'xs', rounded: 'full' }),
                'bg-cyan-400'
              )}
            >
              Button xs
            </button>
            <button
              className={cn(
                baseButtonVariants({ size: 'sm', rounded: 'full' }),
                'bg-cyan-400'
              )}
            >
              Button sm
            </button>
            <button
              className={cn(
                baseButtonVariants({ size: 'md', rounded: 'full' }),
                'bg-cyan-400'
              )}
            >
              Button md
            </button>
            <button
              className={cn(
                baseButtonVariants({ size: 'lg', rounded: 'full' }),
                'bg-cyan-400'
              )}
            >
              Button lg
            </button>
            <button
              className={cn(
                baseButtonVariants({ size: 'xl', rounded: 'full' }),
                'bg-cyan-400'
              )}
            >
              Button xl
            </button>
          </div>
        </div>
      );
    }

    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 ',
          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 ',
        },
      },
    });
    
    export const buttonVariants = cva(
      'focus:ring-2 focus:ring-zinc-300 focus:ring-offset-2 disabled:opacity-50 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 ',
            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

    import { buttonVariants } from './variants';
    import { baseButtonVariants } from './base';
    import { cn } from '#/utils/tailwind';
    
    const Buttons: React.FC<{ className?: string }> = ({ className }) => {
      return (
        <div className="flex items-end gap-2">
          <button className={cn(baseButtonVariants({ size: 'xs' }), className)}>
            Button xs
          </button>
          <button className={cn(baseButtonVariants({ size: 'sm' }), className)}>
            Button sm
          </button>
          <button className={cn(baseButtonVariants({ size: 'md' }), className)}>
            Button md
          </button>
          <button className={cn(baseButtonVariants({ size: 'lg' }), className)}>
            Button lg
          </button>
          <button className={cn(baseButtonVariants({ size: 'xl' }), className)}>
            Button xl
          </button>
        </div>
      );
    };
    
    export function ButtonsSolid() {
      return (
        <div className="flex flex-col items-center justify-center gap-3">
          <Buttons className={buttonVariants({ variant: 'default' })} />
          <Buttons className={buttonVariants({ variant: 'brand' })} />
          <Buttons className={buttonVariants({ variant: 'destructive' })} />
        </div>
      );
    }

    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 ',
          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 ',
          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 ',
          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

    import { buttonVariants } from './variants';
    import { baseButtonVariants } from './base';
    import { cn } from '#/utils/tailwind';
    
    const Buttons: React.FC<{ className?: string; text: string }> = ({
      className,
      text,
    }) => {
      return (
        <div className="flex items-end gap-2">
          <button className={cn(baseButtonVariants({ size: 'xs' }), className)}>
            {text} xs
          </button>
          <button className={cn(baseButtonVariants({ size: 'sm' }), className)}>
            {text} sm
          </button>
          <button className={cn(baseButtonVariants({ size: 'md' }), className)}>
            {text} md
          </button>
          <button className={cn(baseButtonVariants({ size: 'lg' }), className)}>
            {text} lg
          </button>
          <button className={cn(baseButtonVariants({ size: 'xl' }), className)}>
            {text} xl
          </button>
        </div>
      );
    };
    
    export function ButtonsAll() {
      return (
        <div className="flex flex-col items-center justify-center gap-3">
          <Buttons
            text="Solid"
            className={buttonVariants({ variant: 'default' })}
          />
    
          <Buttons
            text="Outline"
            className={buttonVariants({ variant: 'outline' })}
          />
    
          <Buttons text="Ghost" className={buttonVariants({ variant: 'ghost' })} />
    
          <Buttons
            text="Subtle"
            className={buttonVariants({ variant: 'subtle' })}
          />
        </div>
      );
    }

    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.ComponentProps<'button'> &
      VariantProps<typeof baseButtonVariants> & {
        isLoading?: boolean;
        leftIcon?: React.ReactElement;
        rightIcon?: React.ReactElement;
        asChild?: boolean;
      };
    
    export function BaseButton({
      className,
      size,
      rounded,
      leftIcon,
      rightIcon,
      children,
      isLoading,
      disabled,
      asChild,
      ...props
    }: BaseButtonProps) {
      const Comp = (asChild ? Slot.Slot : 'button') as 'button';
    
      const { icon, iconPlacement } = useMemo(() => {
        let icon = rightIcon ? rightIcon : leftIcon;
    
        if (isLoading) {
          icon = <Loader2Icon className="animate-spin-slow" />;
        }
    
        return {
          icon,
          iconPlacement: rightIcon ? ('right' as const) : ('left' as const),
        };
      }, [isLoading, leftIcon, rightIcon]);
    
      return (
        <Comp
          className={cn(baseButtonVariants({ size, rounded, className }))}
          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 asChild>{icon}</Icon>
            </span>
          ) : null}
          <Slot.Slottable>{children}</Slot.Slottable>
          {icon && iconPlacement === 'right' ? (
            <span
              className={cn(
                { 'ml-2': !!children },
                'inline-flex shrink-0 self-center'
              )}
            >
              <Icon asChild>{icon}</Icon>
            </span>
          ) : null}
        </Comp>
      );
    }

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

    export function Button({
      className,
      variant,
      ...props
    }: React.ComponentProps<typeof BaseButton> &
      VariantProps<typeof buttonVariants>) {
      return (
        <BaseButton
          className={cn(buttonVariants({ variant, className }))}
          {...props}
        />
      );
    }

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

    React Buttons

    import { SendIcon, TrashIcon } from 'lucide-react';
    import { Button } from './buttons';
    
    export function ButtonsReact() {
      return (
        <div className="flex gap-2">
          <Button size="md" rounded="md" variant="brand">
            Button
          </Button>
          <Button
            size="md"
            rounded="md"
            variant="destructive"
            leftIcon={<TrashIcon />}
          >
            IconLeft
          </Button>
          <Button size="md" rounded="md" variant="default" rightIcon={<SendIcon />}>
            IconRight
          </Button>
          <Button size="md" rounded="md" variant="default" isLoading>
            Loading
          </Button>
        </div>
      );
    }

    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 function IconButton({
      className,
      children,
      ...props
    }: Omit<React.ComponentProps<typeof Button>, 'leftIcon' | 'rightIcon'>) {
      return (
        <Button {...props} className={cn('p-0', className)}>
          {props.isLoading ? null : children}
        </Button>
      );
    }

    React IconButtons

    import { Rotate3DIcon, SendIcon, ThumbsUpIcon, TrashIcon } from 'lucide-react';
    import { IconButton } from './buttons';
    import { Icon } from './base';
    
    export function ButtonsIconReact() {
      return (
        <div className="flex gap-2">
          <IconButton size="lg" rounded="md" variant="subtle-brand">
            <Icon asChild>
              <ThumbsUpIcon />
            </Icon>
          </IconButton>
          <IconButton size="lg" rounded="md" variant="subtle-destructive">
            <Icon asChild>
              <TrashIcon />
            </Icon>
          </IconButton>
          <IconButton size="lg" rounded="md" variant="subtle">
            <Icon asChild>
              <SendIcon />
            </Icon>
          </IconButton>
          <IconButton size="lg" rounded="md" variant="subtle" isLoading>
            <Icon asChild>
              <Rotate3DIcon />
            </Icon>
          </IconButton>
        </div>
      );
    }