GitHub

Button

Triggers an action or event. Supports five semantic variants, three sizes, a loading spinner state, and full keyboard accessibility.

AccessibleDark Mode5 Variants3 SizesLoading State
01

Theme Preview

All five variants and three sizes across both themes — forced styling for accurate side-by-side comparison.

Light
Dark
02

Live Demo

Variant
Size
03

Code Snippet

src/ui/Button.tsx
import { Button } from "@/ui/Button";

// Variants
<Button.Root variant="primary">Save changes</Button.Root>
<Button.Root variant="secondary">Cancel</Button.Root>
<Button.Root variant="ghost">Learn more</Button.Root>
<Button.Root variant="destructive">Delete account</Button.Root>
<Button.Root variant="outline">View details</Button.Root>

// Sizes
<Button.Root size="sm">Small</Button.Root>
<Button.Root size="md">Medium</Button.Root>
<Button.Root size="lg">Large</Button.Root>

// States
<Button.Root isLoading>Saving...</Button.Root>
<Button.Root disabled>Unavailable</Button.Root>

Backward compatible: API lama <Button /> tetap didukung, tapi style utama sekarang <Button.Root />.

04

Copy-Paste (Single File)

Button.tsx
import type { ButtonHTMLAttributes, ReactNode } from "react";

type ButtonVariant = "primary" | "secondary" | "ghost" | "destructive" | "outline";
type ButtonSize = "sm" | "md" | "lg";

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: ButtonVariant;
  size?: ButtonSize;
  isLoading?: boolean;
  children: ReactNode;
}

const cn = (...classes: Array<string | undefined | false>) => classes.filter(Boolean).join(" ");

const VARIANT_CLASSES: Record<ButtonVariant, string> = {
  primary: "bg-primary text-white hover:bg-primary/90",
  secondary: "bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700",
  ghost: "text-slate-700 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800",
  destructive: "bg-red-600 text-white hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-600",
  outline: "border border-slate-300 text-slate-700 hover:bg-slate-50 dark:border-slate-600 dark:text-slate-300 dark:hover:bg-slate-800/50",
};

const SIZE_CLASSES: Record<ButtonSize, string> = {
  sm: "text-xs px-3 py-1.5 h-7 gap-1.5",
  md: "text-sm px-4 py-2 h-9 gap-2",
  lg: "text-base px-6 py-2.5 h-11 gap-2",
};

function Spinner() {
  return <span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />;
}

function ButtonRoot({ variant = "primary", size = "md", isLoading = false, disabled, children, className, ...props }: ButtonProps) {
  return (
    <button
      {...props}
      disabled={disabled || isLoading}
      className={cn(
        "inline-flex items-center justify-center rounded-lg font-semibold transition-all",
        "focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-primary/40",
        "disabled:cursor-not-allowed disabled:opacity-50",
        VARIANT_CLASSES[variant],
        SIZE_CLASSES[size],
        className,
      )}
    >
      {isLoading && <Spinner />}
      {children}
    </button>
  );
}

type ButtonCompound = typeof ButtonRoot & { Root: typeof ButtonRoot; Spinner: typeof Spinner };
export const Button = Object.assign(ButtonRoot, { Root: ButtonRoot, Spinner }) as ButtonCompound;
05

Props

Extends all native HTMLButtonElement attributes (onClick, type, form, etc.).

PropTypeDefaultDescription
variant"primary" | "secondary" | "ghost" | "destructive" | "outline""primary"Visual style of the button.
size"sm" | "md" | "lg""md"Controls height, padding, and font size.
isLoadingbooleanfalseShows a spinner and disables the button while true.
disabledbooleanfalseDisables interaction and reduces opacity.
childrenReactNodeButton label content.
classNamestringExtra CSS classes merged via cn().
react-principles