The variants pattern allows configuring component appearance via props instead of conditional CSS. It creates a type-safe, consistent, and reusable button system.
How it works step-by-step:
className prop.ButtonHTMLAttributes to inherit all native button props.1import { type ButtonHTMLAttributes, type ReactNode, forwardRef } from "react";23type Variant = "primary" | "secondary" | "ghost" | "danger" | "outline";4type Size = "sm" | "md" | "lg" | "icon";56interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {7 variant?: Variant;8 size?: Size;9 isLoading?: boolean;10 leftIcon?: ReactNode;11 rightIcon?: ReactNode;12 fullWidth?: boolean;13 children: ReactNode;14}1516const variantStyles: Record<Variant, string> = {17 primary: "bg-blue-600 text-white hover:bg-blue-700 active:bg-blue-800 focus:ring-blue-300",18 secondary: "bg-gray-200 text-gray-800 hover:bg-gray-300 active:bg-gray-400 focus:ring-gray-300",19 ghost: "bg-transparent text-gray-600 hover:bg-gray-100 active:bg-gray-200 focus:ring-gray-200",20 danger: "bg-red-600 text-white hover:bg-red-700 active:bg-red-800 focus:ring-red-300",21 outline: "border-2 border-blue-600 text-blue-600 hover:bg-blue-50 active:bg-blue-100 focus:ring-blue-300"22};2324const sizeStyles: Record<Size, string> = {25 sm: "px-3 py-1.5 text-sm gap-1.5",26 md: "px-4 py-2 text-base gap-2",27 lg: "px-6 py-3 text-lg gap-2.5",28 icon: "p-2"29};3031export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(32 {33 variant = "primary",34 size = "md",35 isLoading = false,36 leftIcon,37 rightIcon,38 fullWidth = false,39 disabled,40 children,41 className = "",42 ...props43 },44 ref45) {46 const isDisabled = disabled || isLoading;4748 return (49 <button50 ref={ref}51 className={`52 inline-flex items-center justify-center53 rounded-lg font-medium transition-colors54 focus:outline-none focus:ring-2 focus:ring-offset-255 disabled:opacity-50 disabled:cursor-not-allowed56 ${variantStyles[variant]}57 ${sizeStyles[size]}58 ${fullWidth ? "w-full" : ""}59 ${className}60 `}61 disabled={isDisabled}62 aria-disabled={isDisabled}63 aria-busy={isLoading}64 {...props}65 >66 {isLoading ? (67 <>68 <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">69 <circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />70 </svg>71 <span>Loading...</span>72 </>73 ) : (74 <>75 {leftIcon}76 {children}77 {rightIcon}78 </>79 )}80 </button>81 );82});8384// Usage examples85<Button variant="primary" size="lg">Submit</Button>86<Button variant="ghost" leftIcon={<ArrowLeftIcon />}>Back</Button>87<Button variant="danger" isLoading>Delete Account</Button>88<Button variant="outline" fullWidth>Full Width Outline</Button>89<Button variant="secondary" size="sm">Small Secondary</Button>
Performance considerations:
forwardRef ensures the button is compatible with parent focus management.variantStyles and sizeStyles objects are static — they live outside the component and are never re-created.aria-disabled alongside disabled for screen readers that may not properly announce the disabled state.Configuration options:
variant — controls color and visual style.size — controls padding and font size.isLoading — shows spinner and disables the button.leftIcon / rightIcon — slot pattern for icons.fullWidth — makes the button take full container width.Integration with design systems:
1// Create a button group for related actions2function ButtonGroup({ children }: { children: React.ReactNode }) {3 return (4 <div className="inline-flex rounded-lg shadow-sm" role="group">5 {children}6 </div>7 );8}910<ButtonGroup>11 <Button variant="secondary" className="rounded-r-none">Left</Button>12 <Button variant="secondary" className="rounded-l-none">Right</Button>13</ButtonGroup>
Benefits: Type safety (TypeScript), reusability, consistency across the app, easy to extend with new variants.