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