Button

Primary action trigger with variants, sizes, icons, loading and disabled-with-reason states.

import { Button }from "@/components/ui"

Specification

Triggers the primary action on a screen. One primary button per view — it signals the most important next step.

Anatomy

  • Container: moderate radius (8px / rounded-lg)
  • Label: 14px medium weight, centered
  • Optional leading/trailing icon: 15–18px, 8px gap to label
  • Focus ring: 2px offset outline for keyboard navigation

States

  • Default: solid brand background, white label
  • Hover: darker background (accent-hover)
  • Active/Pressed: darker still (accent-active)
  • Disabled: reduced opacity, no pointer events, no hover
  • Loading: spinner replaces content, same background

Dimensions & tokens

Height (md)40px
Padding X16px
Icon gap8px
Icon size16–18px
Radius8px
Min touch target44×44px

Do

  • Use for the single most important action per view
  • Keep the label to 1–3 words
  • Ensure 4.5:1 label/background contrast
  • Provide a visible focus state for keyboard users

Don't

  • Place two primary buttons in the same view
  • Use for destructive actions — use the destructive variant
  • Disable without explaining why nearby
  • Use ALL CAPS unless a brand guideline requires it

Accessibility

  • Minimum touch target 44×44px on mobile
  • Visible focus indicator (2px ring, 2px offset)
  • Uses a real <button> element
  • aria-label required when icon-only
  • aria-disabled + visual disabled state when blocked

Spec sourced from mathesis ui-component/primary-button

Props (13)
Every prop this component accepts, its type, default, and how to use it.
PropTypeDefaultDescription
variant"primary" | "secondary" | "ghost" | "destructive" | "link""primary"Sets the visual weight and intent of the button.
size"sm" | "md" | "lg""md"Controls the button height, padding and icon size.
loadingboolean—Shows a spinner in place of the content and blocks clicks while an async action runs.
disabledboolean—Disables the button; combine with disabledReason to explain why on hover/focus.
disabledReasonstring—Tooltip text shown when the button is disabled, explaining why the action is blocked.
iconLucideIcon—Leading icon component rendered before the label.
iconRightLucideIcon—Trailing icon component rendered after the label.
childrenReactNode—The button label; keep it to 1–3 words.
onClick(e: MouseEvent<HTMLButtonElement>) => void—Handler invoked when the button is activated (suppressed while disabled with a reason).
titlestring—Native title attribute; ignored when a disabledReason tooltip is shown.
type"button" | "submit" | "reset""button"The native button type; use "submit" inside forms.
classNamestring—Extra classes merged onto the button element.
ariaLabelstring—Accessible label; required when the button is icon-only.

Variants & states

Full source
The complete component, read from src/components/ui/button.tsx.
button.tsx
"use client";

import { useState, useRef, type MouseEvent, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { Loader2, type LucideIcon } from "lucide-react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { usePopoverPosition, useMounted } from "@/hooks";

const button = cva(
  [
    "relative inline-flex items-center justify-center gap-2 whitespace-nowrap",
    "rounded-lg font-medium transition-[color,background-color,border-color,transform,box-shadow] duration-150 select-none",
    "active:scale-[0.97]",
    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-accent",
    "disabled:pointer-events-none disabled:opacity-60",
    "aria-disabled:pointer-events-none aria-disabled:opacity-60",
  ],
  {
    variants: {
      variant: {
        primary:
          "bg-accent text-on-accent hover:bg-accent-hover active:bg-accent-active border border-transparent shadow-accent hover:shadow-accent active:shadow-sm",
        secondary:
          "bg-surface text-text border border-border hover:bg-bg-hover hover:border-border-hover active:bg-bg-active shadow-sm hover:shadow-md active:shadow-sm",
        ghost:
          "bg-transparent text-text-secondary hover:bg-bg-hover hover:text-text active:bg-bg-active border border-transparent",
        destructive:
          "bg-error text-on-error hover:bg-error-hover active:bg-error-hover border border-transparent focus-visible:ring-error shadow-sm hover:shadow-md active:shadow-sm",
        link: "bg-transparent text-accent underline-offset-2 hover:underline p-0 h-auto active:scale-100",
      },
      size: {

        sm: "h-8 min-h-8 px-2.5 text-sm",
        md: "h-10 min-h-10 px-4 text-sm",
        lg: "h-11 min-h-11 px-5 text-base",
      },
    },
    defaultVariants: { variant: "primary", size: "md" },
  }
);

type ButtonVariant = NonNullable<VariantProps<typeof button>["variant"]>;
type ButtonSize = NonNullable<VariantProps<typeof button>["size"]>;

interface ButtonProps {
  variant?: ButtonVariant;
  size?: ButtonSize;
  loading?: boolean;
  disabled?: boolean;
  disabledReason?: string;
  icon?: LucideIcon;
  iconRight?: LucideIcon;
  children?: ReactNode;
  onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
  title?: string;
  type?: "button" | "submit" | "reset";
  className?: string;

  ariaLabel?: string;
}

export function Button({
  variant = "primary",
  size = "md",
  loading,
  disabled,
  disabledReason,
  icon: Icon,
  iconRight: IconR,
  children,
  onClick,
  title,
  type = "button",
  className,
  ariaLabel,
}: ButtonProps) {
  const [showTip, setShowTip] = useState(false);
  const isDisabled = Boolean(disabled) && !loading;
  const explainable = isDisabled && Boolean(disabledReason);
  const wrapRef = useRef<HTMLSpanElement>(null);
  const mounted = useMounted();
  const tipPosition = usePopoverPosition(wrapRef, showTip, {
    align: "center",
    gap: 8,
    minWidth: 140,
    preferredHeight: 60,
  });

  const iconSize = size === "lg" ? 18 : size === "sm" ? 15 : 16;

  const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
    if (explainable) {
      e.preventDefault();
      return;
    }
    onClick?.(e);
  };

  return (
    <span
      ref={wrapRef}
      className="relative inline-flex"
      onMouseEnter={() => explainable && setShowTip(true)}
      onMouseLeave={() => setShowTip(false)}
    >
      <button
        type={type}
        disabled={(isDisabled && !explainable) || loading}
        aria-disabled={explainable || undefined}
        aria-busy={loading || undefined}
        aria-label={ariaLabel}
        onFocus={() => explainable && setShowTip(true)}
        onBlur={() => setShowTip(false)}
        onClick={handleClick}
        title={!explainable ? title : undefined}
        className={cn(button({ variant, size }), className)}
      >
        {loading && <Loader2 size={14} className="absolute animate-spin" aria-hidden />}
        <span
          className="inline-flex items-center gap-2"
          style={{ opacity: loading ? 0 : 1 }}
        >
          {Icon && <Icon size={iconSize} strokeWidth={2.2} aria-hidden />}
          {children}
          {IconR && <IconR size={iconSize} strokeWidth={2.2} aria-hidden />}
        </span>
      </button>
      {explainable && showTip && tipPosition && mounted &&
        createPortal(
          <span
            role="tooltip"
            className="pointer-events-none z-50 w-max max-w-72 whitespace-pre-line rounded-md bg-surface-dark px-2.5 py-1.5 text-left text-xs leading-snug text-on-dark shadow-lg"
            style={tipPosition.style}
          >
            {disabledReason}
            <span
              className="absolute left-1/2 size-2 -translate-x-1/2 rotate-45 bg-surface-dark"
              style={tipPosition.placement === "top" ? { bottom: -4 } : { top: -4 }}
            />
          </span>,
          document.body
        )}
    </span>
  );
}

Dependencies

To use Button you also need the following. Copy these into your project alongside it.

Recommended folder structure

project structure
src/components/ui/
  └─ button.tsx
src/hooks/
  ├─ use-mounted.ts
  └─ use-popover-position.ts
src/lib/
  └─ utils.ts

Dependency files

These supporting files aren’t components — copy each one into the matching path above.

src/hooks/use-mounted.ts
use-mounted.ts
"use client";

import { useState, useEffect } from "react";

/**
 * Returns true once the component has mounted on the client.
 * Useful for portals and anything that needs `document` to exist.
 */
export function useMounted(): boolean {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  return mounted;
}
src/hooks/use-popover-position.ts
use-popover-position.ts
"use client";

import { useState, useEffect, useCallback, type RefObject, type CSSProperties } from "react";

interface PopoverPositionOptions {
  align?: "start" | "center" | "end";
  gap?: number;
  viewportPadding?: number;
  preferredHeight?: number;
  matchWidth?: boolean;
  minWidth?: number;
}

interface PopoverPosition {
  placement: "top" | "bottom";
  style: CSSProperties;
}

/**
 * Compute a fixed-position popover style anchored to a ref element.
 * Opens downward by default; flips upward when there isn't room below
 * AND there's more room above. Clamps horizontally so nothing renders
 * past the viewport edge. Re-measures on scroll/resize while open.
 */
export function usePopoverPosition(
  anchorRef: RefObject<HTMLElement | null>,
  open: boolean,
  options: PopoverPositionOptions = {}
): PopoverPosition | null {
  const {
    align = "start",
    gap = 4,
    viewportPadding = 8,
    preferredHeight = 320,
    matchWidth = false,
    minWidth,
  } = options;

  const [position, setPosition] = useState<PopoverPosition | null>(null);

  const measure = useCallback(() => {
    const anchor = anchorRef.current;
    if (!anchor) return;
    const rect = anchor.getBoundingClientRect();
    const vw = window.innerWidth;
    const vh = window.innerHeight;
    const spaceBelow = vh - rect.bottom - viewportPadding;
    const spaceAbove = rect.top - viewportPadding;
    const placement =
      spaceBelow < Math.min(preferredHeight, 160) && spaceAbove > spaceBelow
        ? "top"
        : "bottom";
    const maxHeight = Math.max(
      120,
      Math.min(preferredHeight, placement === "top" ? spaceAbove : spaceBelow)
    );
    const width = matchWidth ? rect.width : minWidth;
    let left = align === "end" && width ? rect.right - width : rect.left;
    if (align === "center" && width)
      left = rect.left + rect.width / 2 - width / 2;
    left = width
      ? Math.min(Math.max(left, viewportPadding), vw - width - viewportPadding)
      : Math.min(Math.max(left, viewportPadding), vw - viewportPadding);

    setPosition({
      placement,
      style: {
        position: "fixed",
        top: placement === "top" ? undefined : rect.bottom + gap,
        bottom: placement === "top" ? vh - rect.top + gap : undefined,
        left,
        width: matchWidth ? rect.width : undefined,
        minWidth: !matchWidth ? minWidth : undefined,
        maxHeight,
      },
    });
  }, [anchorRef, align, gap, viewportPadding, preferredHeight, matchWidth, minWidth]);

  useEffect(() => {
    if (!open) {
      setPosition(null);
      return;
    }
    measure();
    window.addEventListener("scroll", measure, true);
    window.addEventListener("resize", measure);
    return () => {
      window.removeEventListener("scroll", measure, true);
      window.removeEventListener("resize", measure);
    };
  }, [open, measure]);

  return position;
}
src/lib/utils.ts
utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

/**
 * Merge class names with clsx semantics + Tailwind conflict resolution.
 * Prefer this in all components.
 */
export function cn(...inputs: ClassValue[]): string {
  return twMerge(clsx(inputs));
}

/**
 * Concatenate class names, filtering out falsy values.
 * @deprecated Prefer {@link cn} — it also resolves Tailwind class conflicts.
 */
export function cx(...args: (string | false | null | undefined)[]): string {
  return args.filter(Boolean).join(" ");
}

/**
 * Focus ring style helper.
 * @deprecated Focus rings are now handled globally via the
 * `:focus-visible` rule in `globals.css` and the `focus-visible:*`
 * utilities. This remains only for backwards compatibility.
 */
export function ring(color: string = "var(--color-accent)") {
  return { outlineColor: color };
}