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
- Label, spinner and check sit in the same grid cell (col-start-1 row-start-1) and cross-fade with opacity, so the button never changes width between states.
- The page owns the status, the button only displays it. That keeps the component dumb and the upload logic swappable.
- Variant and size classes live in lookup objects, merged with cn (clsx + tailwind-merge) so a caller's className can override them.
- Busy buttons disable themselves, but only dim when the caller passes disabled, so the loading state stays readable.
- active:scale-[0.98] gives a small press, turned off under prefers-reduced-motion.