Button
A clickable element that triggers actions — the most fundamental interactive component in any application.
Use Cases
- Submit forms (login, signup, checkout)
- Trigger navigation or route changes
- Open modals, dropdowns, or drawers
- Confirm destructive actions (delete, cancel)
- Toggle states (like/unlike, follow/unfollow)
- Copy text to clipboard
- Download files or export data
Simplest Implementation
"use client";
interface ButtonProps {
children: React.ReactNode;
onClick?: () => void;
}
export function Button({ children, onClick }: ButtonProps) {
return (
<button
onClick={onClick}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 active:bg-blue-800"
>
{children}
</button>
);
}A minimal button with hover and active states. The onClick is optional so the button can also be used as a type="submit" inside forms without needing a handler.
Variations
Variant Styles
"use client";
type Variant = "primary" | "secondary" | "danger" | "ghost" | "outline";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
children: React.ReactNode;
}
const variantClasses: Record<Variant, string> = {
primary: "bg-blue-600 text-white hover:bg-blue-700 active:bg-blue-800",
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200 active:bg-gray-300",
danger: "bg-red-600 text-white hover:bg-red-700 active:bg-red-800",
ghost: "bg-transparent text-gray-700 hover:bg-gray-100 active:bg-gray-200",
outline: "border border-gray-300 bg-transparent text-gray-700 hover:bg-gray-50 active:bg-gray-100",
};
export function Button({ variant = "primary", children, className, ...rest }: ButtonProps) {
return (
<button
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50 disabled:pointer-events-none ${variantClasses[variant]} ${className ?? ""}`}
{...rest}
>
{children}
</button>
);
}Uses a Record to map variant names to Tailwind classes. Extends ButtonHTMLAttributes so all native attributes (disabled, type, form, etc.) pass through automatically.
Size Variants
"use client";
type Size = "sm" | "md" | "lg";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
size?: Size;
children: React.ReactNode;
}
const sizeClasses: Record<Size, string> = {
sm: "px-3 py-1.5 text-xs",
md: "px-4 py-2 text-sm",
lg: "px-6 py-3 text-base",
};
export function Button({ size = "md", children, ...rest }: ButtonProps) {
return (
<button
className={`rounded-lg bg-blue-600 font-medium text-white hover:bg-blue-700 ${sizeClasses[size]}`}
{...rest}
>
{children}
</button>
);
}Separating size from variant keeps the class logic manageable. Combine with the variant pattern for a full button system.
Loading State
"use client";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
loading?: boolean;
children: React.ReactNode;
}
export function Button({ loading, children, disabled, ...rest }: ButtonProps) {
return (
<button
disabled={loading || disabled}
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50 disabled:pointer-events-none"
{...rest}
>
{loading && (
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
)}
{children}
</button>
);
}The spinner SVG is inline so no icon library is needed. The button disables itself while loading to prevent double-clicks.
Icon Button
"use client";
interface IconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
label: string;
children: React.ReactNode;
}
export function IconButton({ label, children, ...rest }: IconButtonProps) {
return (
<button
aria-label={label}
className="inline-flex h-10 w-10 items-center justify-center rounded-lg text-gray-600 hover:bg-gray-100 active:bg-gray-200"
{...rest}
>
{children}
</button>
);
}
// Usage
<IconButton label="Close menu" onClick={onClose}>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</IconButton>Icon-only buttons require aria-label for accessibility. The fixed h-10 w-10 keeps the click target consistent regardless of icon size.
Button as Link (Polymorphic)
"use client";
import Link from "next/link";
type ButtonAsLinkProps = {
href: string;
children: React.ReactNode;
className?: string;
};
type ButtonAsButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
href?: never;
children: React.ReactNode;
};
type ButtonProps = ButtonAsLinkProps | ButtonAsButtonProps;
export function Button(props: ButtonProps) {
const base = "inline-flex items-center justify-center rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700";
if ("href" in props && props.href) {
const { href, children, className } = props;
return (
<Link href={href} className={`${base} ${className ?? ""}`}>
{children}
</Link>
);
}
const { children, className, ...rest } = props as ButtonAsButtonProps;
return (
<button className={`${base} ${className ?? ""}`} {...rest}>
{children}
</button>
);
}A discriminated union on href lets the same component render as either a <button> or a Next.js <Link>. TypeScript enforces that href and onClick don't mix incorrectly.
Form Submit Button with useFormStatus
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton({ children }: { children: React.ReactNode }) {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{pending && (
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
)}
{children}
</button>
);
}useFormStatus must be called in a component that is a child of a <form>. It reads the pending state from the parent form's action, so you don't need to pass loading props manually.
Complex Implementation
"use client";
import { forwardRef } from "react";
import Link from "next/link";
type Variant = "primary" | "secondary" | "danger" | "ghost" | "outline";
type Size = "sm" | "md" | "lg" | "icon";
interface BaseProps {
variant?: Variant;
size?: Size;
loading?: boolean;
fullWidth?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
children?: React.ReactNode;
}
type ButtonAsButton = BaseProps &
Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, keyof BaseProps> & {
href?: never;
};
type ButtonAsLink = BaseProps &
Omit<React.ComponentPropsWithoutRef<typeof Link>, keyof BaseProps> & {
href: string;
disabled?: boolean;
};
type ButtonProps = ButtonAsButton | ButtonAsLink;
const variantClasses: Record<Variant, string> = {
primary: "bg-blue-600 text-white hover:bg-blue-700 active:bg-blue-800 focus-visible:ring-blue-500",
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring-gray-400",
danger: "bg-red-600 text-white hover:bg-red-700 active:bg-red-800 focus-visible:ring-red-500",
ghost: "bg-transparent text-gray-700 hover:bg-gray-100 active:bg-gray-200 focus-visible:ring-gray-400",
outline: "border border-gray-300 text-gray-700 hover:bg-gray-50 active:bg-gray-100 focus-visible:ring-gray-400",
};
const sizeClasses: Record<Size, string> = {
sm: "h-8 px-3 text-xs gap-1.5",
md: "h-10 px-4 text-sm gap-2",
lg: "h-12 px-6 text-base gap-2.5",
icon: "h-10 w-10 p-0",
};
function Spinner() {
return (
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
);
}
export const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>(
function Button(props, ref) {
const {
variant = "primary",
size = "md",
loading = false,
fullWidth = false,
leftIcon,
rightIcon,
children,
className,
disabled,
...rest
} = props;
const classes = [
"inline-flex items-center justify-center rounded-lg font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
"disabled:opacity-50 disabled:pointer-events-none",
variantClasses[variant],
sizeClasses[size],
fullWidth ? "w-full" : "",
loading ? "pointer-events-none" : "",
className ?? "",
]
.filter(Boolean)
.join(" ");
const content = (
<>
{loading ? <Spinner /> : leftIcon}
{children}
{!loading && rightIcon}
</>
);
if ("href" in rest && rest.href) {
const { href, ...linkRest } = rest as ButtonAsLink;
return (
<Link
ref={ref as React.Ref<HTMLAnchorElement>}
href={href}
className={classes}
aria-disabled={disabled || loading}
tabIndex={disabled || loading ? -1 : undefined}
{...linkRest}
>
{content}
</Link>
);
}
return (
<button
ref={ref as React.Ref<HTMLButtonElement>}
disabled={disabled || loading}
className={classes}
{...(rest as Omit<ButtonAsButton, keyof BaseProps>)}
>
{content}
</button>
);
}
);Key aspects:
- Polymorphic rendering — discriminated union on
hrefswitches between<button>and<Link>while maintaining correct TypeScript types for both. - forwardRef — allows parent components to attach refs for focus management, scroll-to, or animation libraries.
- Focus-visible ring —
focus-visible:ring-2 focus-visible:ring-offset-2shows a focus ring only on keyboard navigation, not mouse clicks. - Loading replaces leftIcon — the spinner takes the leftIcon's slot, preventing layout shift during loading.
- Link disabled pattern — links can't truly be disabled, so
aria-disabledandtabIndex={-1}simulate it whilepointer-events-noneprevents clicks. - Class composition — array of class strings joined with
filter(Boolean)keeps things readable and avoids extra spaces from conditional empty strings.
Gotchas
-
Missing
type="button"on non-submit buttons — Buttons inside a<form>default totype="submit", which submits the form on click. Always addtype="button"for buttons that aren't meant to submit. -
Using
<a>instead of<Link>for internal navigation — Plain<a>tags cause a full page reload. Use Next.js<Link>for client-side navigation. -
Disabled buttons swallowing events — A disabled button doesn't fire
onClick, which breaks tooltip or popover triggers. Wrap the button in a<span>if you need events on a disabled button. -
Missing accessible name on icon buttons — A button with only an SVG icon has no text for screen readers. Always add
aria-labelto icon-only buttons. -
Loading state without disabling — Showing a spinner but not disabling the button allows double-submissions. Always set
disabled={true}orpointer-events-nonewhen loading. -
Inline arrow functions causing unnecessary re-renders —
onClick={() => doSomething(id)}creates a new function each render. This rarely matters, but in a list of 100+ buttons, extract the handler or useuseCallback.
Related
- Forms — Form submission patterns that use buttons
- Event Handling — onClick and event handler typing
- Tailwind Utilities — Utility classes used in button styling