React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

buttoncomponentinteractivetailwind

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.

"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 href switches 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 ringfocus-visible:ring-2 focus-visible:ring-offset-2 shows 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-disabled and tabIndex={-1} simulate it while pointer-events-none prevents 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 to type="submit", which submits the form on click. Always add type="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-label to icon-only buttons.

  • Loading state without disabling — Showing a spinner but not disabling the button allows double-submissions. Always set disabled={true} or pointer-events-none when loading.

  • Inline arrow functions causing unnecessary re-rendersonClick={() => doSomething(id)} creates a new function each render. This rarely matters, but in a list of 100+ buttons, extract the handler or use useCallback.