Back

Geist Button

September 2026

A recreation of Vercel's Geist button: five variants, three sizes, and a loading → success state. Click Upload.

components/button.tsx
import React from "react";
import { cn } from "@/lib/utils";
import { LoaderCircle, Check } from "lucide-react";

type Size = "small" | "medium" | "large";
type Variant = "primary" | "secondary" | "tertiary" | "error" | "warning";

const sizeClasses: Record<Size, string> = {
  small: "h-8 px-3 text-sm",
  medium: "h-9 px-3 text-sm",
  large: "h-10 px-4 text-base",
};

// Shared by every button, whatever its variant or size.
const baseClasses =
  "motion-reduce:active:scale-100 rounded-md transition duration-150 active:scale-[0.98] outline-none focus-visible:ring-2 focus-visible:ring-blue-500";

// Only what's unique to each variant: resting colors, then hover, then pressed (active).
const variantClasses: Record<Variant, string> = {
  primary: "bg-white text-black hover:bg-gray-200 active:bg-gray-300",
  error: "bg-error text-white hover:bg-[#FF6166] active:bg-[#FF8A8E]",
  warning: "bg-warning text-black hover:bg-[#D27504] active:bg-[#B06103]",
  secondary:
    "border border-solid border-gray-700 text-white hover:bg-[#1F1F1F] active:bg-[#2A2A2A]",
  tertiary: "text-white hover:bg-[#202020] active:bg-[#2E2E2E]",
};

type ButtonProps = React.ComponentProps<"button"> & {
  variant?: Variant;
  size?: Size;
  status?: "idle" | "loading" | "success";
};

export function Button({
  size = "small",
  variant = "primary",
  status = "idle",
  children,
  className,
  disabled,
  ...props
}: ButtonProps) {
  return (
    <span>
      {/* Final className = shared + variant + size */}
      <button
        className={cn(
          "grid place-items-center",
          baseClasses,
          variantClasses[variant],
          sizeClasses[size],
          disabled && "opacity-50 cursor-not-allowed",
          className,
        )}
        {...props}
        disabled={disabled || status !== "idle"}
      >
        {/* Layer 1: the label */}
        <span
          className={`col-start-1 row-start-1 transition-opacity duration-150 ${
            status === "idle" ? "opacity-100" : "opacity-0"
          }`}
        >
          {children}
        </span>

        {/* Layer 2: the spinner */}
        <span
          className={`col-start-1 row-start-1 transition-opacity duration-150 ${
            status === "loading" ? "opacity-100" : "opacity-0"
          }`}
        >
          <LoaderCircle className="size-4 animate-spin" />
        </span>

        {/* Layer 3: the checkmark */}
        <span
          className={`col-start-1 row-start-1 transition-opacity duration-150 ${
            status === "success" ? "opacity-100" : "opacity-0"
          }`}
        >
          <Check className="size-4" strokeWidth={2.5} />
        </span>
      </button>
    </span>
  );
}

Notes