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.
| Prop | Type | Default | Description |
|---|---|---|---|
| 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. |
| loading | boolean | — | Shows a spinner in place of the content and blocks clicks while an async action runs. |
| disabled | boolean | — | Disables the button; combine with disabledReason to explain why on hover/focus. |
| disabledReason | string | — | Tooltip text shown when the button is disabled, explaining why the action is blocked. |
| icon | LucideIcon | — | Leading icon component rendered before the label. |
| iconRight | LucideIcon | — | Trailing icon component rendered after the label. |
| children | ReactNode | — | 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). |
| title | string | — | Native title attribute; ignored when a disabledReason tooltip is shown. |
| type | "button" | "submit" | "reset" | "button" | The native button type; use "submit" inside forms. |
| className | string | — | Extra classes merged onto the button element. |
| ariaLabel | string | — | 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 };
}