Components

Getting started

Components117

More

The fundamental action: 5 variants, 4 sizes, adaptive press depth, in-place loading spinner, and spring width morphing.

Used forForm submitsToolbars & headersDestructive dialogsIcon triggers

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/button.json

Where it’s used

  • Form submits

    Primary actions with in-place loading state that retains keyboard focus.

  • Toolbars & headers

    Secondary and ghost buttons with hairline borders and adaptive press feel.

  • Destructive dialogs

    Clean destructive actions with clear visual distinction and no blurry glow.

  • Icon triggers

    Concentric square icon buttons with deeper tactile push feedback.

What it handles for you

Every product needs buttons, but most libraries ship generic flat elements that snap abruptly when text changes and scale clumsily when clicked. This button delivers Apple-grade tactile feedback in 0 kB of runtime animation.

Instead of wiring upgeneric shadcn buttonframer-motion whileTapabrupt layout snapsloading focus drop
  1. Adaptive press scale

    Small icon buttons push deeper (0.95), wide buttons push subtly (0.985), and floating anchor triggers never scale so popover bounds remain pixel-perfect.

  2. Smooth width morphing

    When children or labels change, the button measures the new natural width and glides smoothly without jumping surrounding layout.

  3. In-place loading state

    Loading morphs a centered spinner into place while keeping the button focusable (aria-disabled) so keyboard users never lose their place.

  4. Hairline 1px precision

    Pure 1px border tokens with zero fuzzy drop shadows and full dark mode compatibility.

Overview

A high-craft standard button engineered with hairline borders, zero drop shadows, adaptive aspect-ratio press scale, in-place loading state that retains keyboard focus, and smooth width morphing on label changes without external animation libraries.

States

idle
Resting button with crisp 1px hairline border.
pressed
Tactile press depth tuned to the button’s aspect ratio.
loading
In-place centered spinner, aria-busy, click-swallowing while retaining keyboard focus.
disabled
Subtle 50% opacity and disabled cursor.

Usage

usage.tsx
import { Button } from "@/components/ui/button";<Button variant="primary" size="md">Save changes</Button><Button variant="secondary" loading={isSaving}>Deploy</Button>

Works with

With an async action

Retain keyboard focus and provide smooth in-place feedback during async requests.

example.tsx
"use client";import { useState } from "react";import { Button } from "@/components/ui/button";export function SubmitAction() {  const [loading, setLoading] = useState(false);  const handleSubmit = async () => {    setLoading(true);    try {      await fetch("/api/submit", { method: "POST" });    } finally {      setLoading(false);    }  };  return (    <Button variant="primary" loading={loading} onClick={handleSubmit}>      Publish post    </Button>  );}

Props

PropTypeDescription
variant"primary" | "secondary" | "outline" | "ghost" | "destructive" | "link"default "primary"Visual appearance of the button.
size"xs" | "sm" | "md" | "lg" | "icon"default "md"Height and typographic scale.
loadingbooleandefault falseShows an in-place spinner while retaining keyboard focus.
iconReactNodeLeading icon slot.
trailingIconReactNodeTrailing icon slot.
disabledbooleandefault falseDisables user interaction.

Accessibility and motion

  • No external animation libraries: uses CSS transitions and native layout geometry.
  • Anchor triggers for Popovers and Dropdowns are guarded from press scaling to preserve positioning geometry.
  • Strict 1px hairline styling with zero drop shadows.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs ui-interaction-motion, Text morph.

Source

components/button.tsx
"use client";import * as React from "react";import { TextMorph } from "./text-morph";import { EASE_OUT, EASE_SETTLE } from "./ui-interaction-motion";export type ButtonVariant =  | "primary"  | "brand"  | "secondary"  | "outline"  | "ghost"  | "destructive"  | "link";export type ButtonSize = "xs" | "sm" | "md" | "lg" | "icon";export type ActionStatus = "idle" | "pending" | "success" | "error";export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {  variant?: ButtonVariant;  size?: ButtonSize;  loading?: boolean;  status?: ActionStatus;  /** Custom labels for each state when using status/async flows. */  statusLabels?: Partial<Record<ActionStatus, string>>;  /** Automatically reset back to idle after success/error (ms). Default 1800ms. */  resetAfter?: number;  onReset?: () => void;  icon?: React.ReactNode;  trailingIcon?: React.ReactNode;}const FOCUS =  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background";const variantStyles: Record<ButtonVariant, string> = {  primary:    "bg-primary text-primary-foreground hover:bg-primary/90 shadow-[0_1px_2px_rgba(48,93,222,0.18)] active:scale-[0.97]",  brand:    "bg-primary text-primary-foreground hover:bg-primary/90 shadow-[0_1px_2px_rgba(48,93,222,0.18)] active:scale-[0.97]",  secondary:    "bg-card text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent active:scale-[0.97]",  outline:    "bg-transparent text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent active:scale-[0.97]",  ghost:    "bg-transparent text-muted-foreground hover:bg-accent hover:text-foreground active:scale-[0.97]",  destructive:    "bg-red-500/10 text-red-600 shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_30%,transparent)] dark:text-red-400 hover:bg-red-500/15 active:scale-[0.97]",  link:    "bg-transparent text-primary hover:underline p-0 h-auto font-normal active:scale-100",};interface SizeConfig {  height: string;  padding: string;  fontSize: string;  iconSize: number;  iconGap: number;}const sizeConfigs: Record<ButtonSize, SizeConfig> = {  xs: {    height: "h-6",    padding: "px-2.5",    fontSize: "text-[11px]",    iconSize: 11,    iconGap: 5,  },  sm: {    height: "h-7",    padding: "px-3",    fontSize: "text-[12px]",    iconSize: 13,    iconGap: 6,  },  md: {    height: "h-9",    padding: "px-4",    fontSize: "text-[13px]",    iconSize: 14,    iconGap: 7,  },  lg: {    height: "h-10",    padding: "px-5",    fontSize: "text-[14px]",    iconSize: 16,    iconGap: 8,  },  icon: {    height: "size-9 p-0 justify-center items-center shrink-0",    padding: "p-0",    fontSize: "text-[13px]",    iconSize: 15,    iconGap: 0,  },};const reducedQuery = "(prefers-reduced-motion: reduce)";const subscribeReduced = (onChange: () => void) => {  const query = window.matchMedia(reducedQuery);  query.addEventListener("change", onChange);  return () => query.removeEventListener("change", onChange);};const useReducedMotion = () =>  React.useSyncExternalStore(    subscribeReduced,    () => window.matchMedia(reducedQuery).matches,    () => false  );function SelfDrawingCheck({ drawn, reduced, size }: { drawn: boolean; reduced: boolean; size: number }) {  return (    <svg width={size} height={size} viewBox="0 0 16 16" fill="none" className="shrink-0">      <path        d="M3.5 8.5 6.5 11.5 12.5 4.5"        stroke="currentColor"        strokeWidth="2"        strokeLinecap="round"        strokeLinejoin="round"        pathLength={1}        strokeDasharray={1}        style={{          strokeDashoffset: drawn ? 0 : 1,          transition: drawn && !reduced ? `stroke-dashoffset 420ms ${EASE_OUT} 100ms` : "none",        }}      />    </svg>  );}function StatusAlert({ size }: { size: number }) {  return (    <svg width={size} height={size} viewBox="0 0 16 16" fill="none" className="shrink-0">      <circle cx="8" cy="8" r="6.25" stroke="currentColor" strokeWidth="1.5" />      <path d="M8 4.75v3.75" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />      <circle cx="8" cy="11" r="1" fill="currentColor" />    </svg>  );}const DEFAULT_LABELS: Record<ActionStatus, string> = {  idle: "Save",  pending: "Saving...",  success: "Saved",  error: "Try again",};/** * Hairline Button: high-craft tactile action primitive with rounded-full pill geometry, * StatusButton visual parity, self-drawing checkmark morphs, TextMorph label transitions, * and error shake physics. */export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function Button(  {    variant = "primary",    size = "md",    loading = false,    status,    statusLabels,    resetAfter = 1800,    onReset,    disabled = false,    icon,    trailingIcon,    className = "",    children,    onClick,    ...props  },  forwardedRef) {  const innerRef = React.useRef<HTMLButtonElement | null>(null);  const reduced = useReducedMotion();  React.useImperativeHandle(forwardedRef, () => innerRef.current as HTMLButtonElement);  // Normalize status from explicit prop or loading boolean  const currentStatus: ActionStatus = status ?? (loading ? "pending" : "idle");  const isAsyncFlow = status !== undefined || loading;  // Resolve current text label  const idleLabel = typeof children === "string" ? children : DEFAULT_LABELS.idle;  const labels: Record<ActionStatus, string> = {    idle: statusLabels?.idle ?? idleLabel,    pending: statusLabels?.pending ?? DEFAULT_LABELS.pending,    success: statusLabels?.success ?? DEFAULT_LABELS.success,    error: statusLabels?.error ?? DEFAULT_LABELS.error,  };  const activeLabel = labels[currentStatus];  // Auto-reset timer for success or error states  React.useEffect(() => {    if ((currentStatus !== "success" && currentStatus !== "error") || !onReset) return;    const timer = window.setTimeout(onReset, resetAfter);    return () => window.clearTimeout(timer);  }, [currentStatus, onReset, resetAfter]);  // Error state horizontal shake  React.useEffect(() => {    if (currentStatus !== "error" || reduced) return;    innerRef.current?.animate(      [        { transform: "none" },        { transform: "translateX(-4px)" },        { transform: "translateX(4px)" },        { transform: "translateX(-2px)" },        { transform: "none" },      ],      { duration: 380, easing: "ease-out" }    );  }, [currentStatus, reduced]);  const cfg = sizeConfigs[size];  const isBusy = currentStatus === "pending";  const isInert = disabled || isBusy;  // Icon slot state: open if an icon was provided, or if in async status  const showLeadingSlot =    size !== "icon" && (Boolean(icon) || (isAsyncFlow && currentStatus !== "idle"));  const icons: Record<ActionStatus, React.ReactNode> = {    idle: icon,    pending: (      <span        style={{ width: cfg.iconSize, height: cfg.iconSize }}        className={`rounded-full border-[1.5px] border-current border-t-transparent motion-reduce:animate-none opacity-85 ${          currentStatus === "pending" ? "animate-spin" : ""        }`}      />    ),    success: (      <span className={variant === "primary" ? "text-primary-foreground" : "text-emerald-600 dark:text-emerald-400"}>        <SelfDrawingCheck drawn={currentStatus === "success"} reduced={reduced} size={cfg.iconSize} />      </span>    ),    error: (      <span className="text-red-600 dark:text-red-400">        <StatusAlert size={cfg.iconSize} />      </span>    ),  };  const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {    if (isInert) {      e.preventDefault();      return;    }    onClick?.(e);  };  // Tone shifts for error states  const toneClass =    currentStatus === "error"      ? "bg-red-500/10 text-red-600 shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_30%,transparent)] dark:text-red-400"      : variantStyles[variant];  return (    <>      <button        ref={innerRef}        type={props.type ?? "button"}        disabled={disabled}        aria-disabled={isInert ? true : undefined}        aria-busy={isBusy ? true : undefined}        onClick={handleClick}        className={`relative inline-flex items-center justify-center font-medium select-none whitespace-nowrap cursor-pointer rounded-full transition-[background-color,color,box-shadow,transform,padding] duration-200 motion-reduce:transition-none disabled:cursor-not-allowed disabled:opacity-45 ${FOCUS} ${          toneClass        } ${cfg.height} ${cfg.padding} ${cfg.fontSize} ${className}`}        {...props}      >        {/* Leading Icon / Status Slot with explicit width, height, and marginRight */}        {size !== "icon" && (          <span            aria-hidden="true"            className="relative flex shrink-0 items-center justify-center overflow-hidden"            style={{              width: showLeadingSlot ? cfg.iconSize : 0,              height: cfg.iconSize,              marginRight: showLeadingSlot ? cfg.iconGap : 0,              transition: reduced                ? "none"                : `width 360ms ${EASE_OUT}, margin-right 360ms ${EASE_OUT}`,            }}          >            {(Object.keys(icons) as ActionStatus[]).map((st) => (              <span                key={st}                className="absolute inset-0 flex items-center justify-center"                style={{                  opacity: st === currentStatus ? 1 : 0,                  transform: st === currentStatus ? "none" : "scale(0.6)",                  filter: st === currentStatus ? "none" : "blur(3px)",                  transition: reduced                    ? "none"                    : `opacity 260ms ${EASE_OUT}, transform 380ms ${EASE_OUT}, filter 260ms ${EASE_OUT}`,                }}              >                {icons[st]}              </span>            ))}          </span>        )}        {/* Icon-only mode */}        {size === "icon" && (          <span className="inline-flex shrink-0 items-center justify-center">            {currentStatus === "pending" ? (              <span                style={{ width: cfg.iconSize, height: cfg.iconSize }}                className="rounded-full border-[1.5px] border-current border-t-transparent animate-spin opacity-85"              />            ) : currentStatus === "success" ? (              <SelfDrawingCheck drawn={true} reduced={reduced} size={cfg.iconSize} />            ) : (              icon ?? children            )}          </span>        )}        {/* Text Label: uses TextMorph for silky label transitions */}        {size !== "icon" && (          <span className="inline-flex items-center">            {typeof children === "string" || isAsyncFlow ? (              <TextMorph>{activeLabel}</TextMorph>            ) : (              children            )}          </span>        )}        {/* Trailing Icon with guaranteed calibrated spacing */}        {trailingIcon && size !== "icon" && (          <span            aria-hidden="true"            className="inline-flex shrink-0 items-center justify-center"            style={{ marginLeft: cfg.iconGap }}          >            {trailingIcon}          </span>        )}      </button>      {/* Screen Reader live region outside button */}      {isAsyncFlow && (        <span role="status" aria-live="polite" className="sr-only">          {currentStatus === "idle" ? "" : activeLabel}        </span>      )}    </>  );});Button.displayName = "Button";export default Button;

Registry item: /r/button.json