Talisson Costa
← All components

Button

Five variants, three sizes, a press that gives, and a loading state that keeps the width.

  • cva
  • active:scale
  • aria-busy
  • CSS-only transitions

“Save changes” keeps its width while loading.

Install

npx shadcn@latest add https://tcosta.dev/r/button.json

Or copy the files below into components/button/. They use the shadcn/ui theme tokens and cn from @/lib/utils, and need class-variance-authority.

Source

button.tsx
import type { VariantProps } from 'class-variance-authority';
import type { ComponentProps, MouseEvent } from 'react';
import { cn } from '@/lib/utils';
import { buttonVariants } from './button-variants';
import { Spinner } from './spinner';

export type ButtonProps = ComponentProps<'button'> &
  VariantProps<typeof buttonVariants> & {
    /** Shows a spinner in place of the content, keeping the button's width. Stays focusable. */
    loading?: boolean;
  };

export function Button({
  variant,
  size,
  loading = false,
  type = 'button',
  className,
  onClick,
  children,
  ...props
}: ButtonProps) {
  const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
    if (loading) {
      event.preventDefault();
      return;
    }
    onClick?.(event);
  };

  return (
    <button
      type={type}
      aria-busy={loading || undefined}
      onClick={handleClick}
      className={cn(buttonVariants({ variant, size }), className)}
      {...props}
    >
      {/* The content stays in the layout while loading, so the width never jumps. */}
      <span
        className={cn(
          'inline-flex items-center gap-[inherit] transition-[opacity,filter] duration-150 motion-reduce:transition-none',
          loading && 'opacity-0 blur-[2px]',
        )}
      >
        {children}
      </span>
      <span
        className={cn(
          'absolute inset-0 flex items-center justify-center transition-[opacity,scale] duration-150 motion-reduce:transition-none',
          loading ? 'scale-100 opacity-100' : 'scale-50 opacity-0',
        )}
      >
        <Spinner />
      </span>
    </button>
  );
}
button-variants.ts
import { cva } from 'class-variance-authority';

export const buttonVariants = cva(
  [
    'relative inline-flex shrink-0 cursor-pointer items-center justify-center rounded-md text-sm font-medium whitespace-nowrap outline-none select-none',
    'transition-[color,background-color,border-color,box-shadow,scale] duration-150 active:scale-[0.97]',
    'focus-visible:ring-[3px] focus-visible:ring-ring/50',
    'disabled:pointer-events-none disabled:opacity-50 aria-busy:cursor-progress',
    "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
  ],
  {
    variants: {
      variant: {
        primary: 'bg-primary text-primary-foreground hover:bg-primary/90',
        secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
        outline: 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
        ghost: 'hover:bg-accent hover:text-accent-foreground',
        destructive: 'bg-destructive/10 text-destructive hover:bg-destructive/20',
      },
      size: {
        sm: 'h-8 gap-1.5 px-3 text-xs',
        md: 'h-9 gap-2 px-4',
        lg: 'h-10 gap-2 px-5',
      },
    },
    defaultVariants: {
      variant: 'primary',
      size: 'md',
    },
  },
);
spinner.tsx
export function Spinner() {
  return (
    <svg viewBox="0 0 24 24" fill="none" className="size-4 animate-spin" aria-hidden>
      <circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
      <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
    </svg>
  );
}
index.ts
export { Button, type ButtonProps } from './button';
export { buttonVariants } from './button-variants';