{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"action-button","type":"registry:component","title":"Action button","description":"Asynchronous button with promise-driven state management, in-place character glyph morphing, self-drawing checkmark, and error shake physics.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/text-morph.json?as=dependency","https://hairlineui.com/r/ui-interaction-motion.json?as=dependency"],"files":[{"path":"registry/action-button.tsx","type":"registry:component","content":"// Hairline UI · action-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 {\n  EASE_OUT,\n  INTERACTION_FOCUS,\n  useInteractionReducedMotion,\n} from \"./ui-interaction-motion\";\n\nexport type ActionStatus = \"idle\" | \"pending\" | \"success\" | \"error\";\n\nexport interface ActionButtonProps\n  extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, \"onClick\"> {\n  /** Async or sync action to execute on click. Automatically handles idle -> pending -> success/error. */\n  action?: () => Promise<unknown> | void;\n  /** Explicit click handler if action is not used. */\n  onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;\n  /** Controlled status state. If passed, overrides internal action state. */\n  status?: ActionStatus;\n  /** Custom labels for each status. */\n  labels?: Partial<Record<ActionStatus, string>>;\n  /** Default label in idle state if children is not provided. */\n  label?: string;\n  /** Leading icon shown in idle state. */\n  icon?: React.ReactNode;\n  /** Visual variant. Defaults to primary. */\n  variant?: \"primary\" | \"secondary\" | \"destructive\" | \"outline\" | \"ghost\";\n  /** Button sizing. Defaults to md (36px). */\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** Duration in ms to show success or error state before auto-reverting to idle. Defaults to 2000ms. */\n  resetAfter?: number;\n  /** Callback fired when auto-reset triggers. */\n  onReset?: () => void;\n}\n\nconst DEFAULT_LABELS: Record<ActionStatus, string> = {\n  idle: \"Save changes\",\n  pending: \"Saving...\",\n  success: \"Saved!\",\n  error: \"Failed to save\",\n};\n\nconst EASE = \"cubic-bezier(0.16, 1, 0.3, 1)\";\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} 120ms` : \"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 variantStyles = {\n  primary:\n    \"bg-primary text-primary-foreground hover:bg-primary/90 shadow-[0_1px_2px_rgba(48,93,222,0.18)] border-transparent\",\n  secondary:\n    \"bg-card text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent border-transparent\",\n  outline:\n    \"bg-transparent text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent border-transparent\",\n  destructive:\n    \"bg-red-500/10 text-red-600 dark:text-red-400 shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_30%,transparent)] hover:bg-red-500/15 border-transparent\",\n  ghost:\n    \"bg-transparent text-muted-foreground hover:bg-accent hover:text-foreground border-transparent\",\n};\n\nconst sizeStyles = {\n  sm: {\n    height: \"h-7 text-[12px] px-3\",\n    iconSize: 13,\n    spinnerSize: \"size-3\",\n  },\n  md: {\n    height: \"h-9 text-[13px] px-4\",\n    iconSize: 14,\n    spinnerSize: \"size-3.5\",\n  },\n  lg: {\n    height: \"h-10 text-[14px] px-5\",\n    iconSize: 16,\n    spinnerSize: \"size-4\",\n  },\n};\n\nexport function ActionButton({\n  action,\n  onClick,\n  status: controlledStatus,\n  labels,\n  label: defaultLabel,\n  icon,\n  variant = \"primary\",\n  size = \"md\",\n  resetAfter = 2000,\n  onReset,\n  children,\n  disabled = false,\n  className = \"\",\n  style,\n  ...props\n}: ActionButtonProps) {\n  const reduced = useInteractionReducedMotion();\n  const [internalStatus, setInternalStatus] = React.useState<ActionStatus>(\"idle\");\n  const [pressed, setPressed] = React.useState(false);\n  const btnRef = React.useRef<HTMLButtonElement | null>(null);\n  const resetTimer = React.useRef<number>(0);\n\n  const status = controlledStatus ?? internalStatus;\n  const busy = status === \"pending\";\n  const s = sizeStyles[size];\n\n  // Resolve current label text\n  const initialText = typeof children === \"string\" ? children : defaultLabel;\n  const statusLabels = {\n    ...DEFAULT_LABELS,\n    idle: initialText || DEFAULT_LABELS.idle,\n    ...labels,\n  };\n  const currentLabel = statusLabels[status];\n\n  // Auto reset timer on success or error\n  React.useEffect(() => {\n    if (status !== \"success\" && status !== \"error\") return;\n    if (resetAfter <= 0) return;\n\n    window.clearTimeout(resetTimer.current);\n    resetTimer.current = window.setTimeout(() => {\n      if (controlledStatus === undefined) {\n        setInternalStatus(\"idle\");\n      }\n      onReset?.();\n    }, resetAfter);\n\n    return () => window.clearTimeout(resetTimer.current);\n  }, [status, resetAfter, controlledStatus, onReset]);\n\n  // Shake animation on error\n  React.useEffect(() => {\n    if (status !== \"error\" || reduced || !btnRef.current) return;\n    btnRef.current.animate(\n      [\n        { transform: \"none\" },\n        { transform: \"translateX(-4px)\" },\n        { transform: \"translateX(4px)\" },\n        { transform: \"translateX(-2px)\" },\n        { transform: \"translateX(2px)\" },\n        { transform: \"none\" },\n      ],\n      { duration: 380, easing: \"ease-out\" }\n    );\n  }, [status, reduced]);\n\n  // Click / execution handler\n  const handleClick = async (e: React.MouseEvent<HTMLButtonElement>) => {\n    if (busy || disabled) {\n      e.preventDefault();\n      return;\n    }\n\n    if (onClick) {\n      onClick(e);\n    }\n\n    if (action) {\n      e.preventDefault();\n      setInternalStatus(\"pending\");\n      try {\n        await action();\n        setInternalStatus(\"success\");\n      } catch (err) {\n        console.error(\"ActionButton action error:\", err);\n        setInternalStatus(\"error\");\n      }\n    }\n  };\n\n  // Tone resolution for error/success overrides\n  const toneClass =\n    status === \"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      : status === \"success\" && variant === \"destructive\"\n        ? \"bg-emerald-500/10 text-emerald-600 shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-emerald-500)_30%,transparent)] dark:text-emerald-400\"\n        : variantStyles[variant];\n\n  // Current status icon\n  const showLeading = status !== \"idle\" || Boolean(icon);\n\n  return (\n    <button\n      ref={btnRef}\n      type=\"button\"\n      disabled={disabled || busy}\n      aria-busy={busy || undefined}\n      aria-live=\"polite\"\n      onClick={handleClick}\n      onPointerDown={() => !busy && !disabled && setPressed(true)}\n      onPointerUp={() => setPressed(false)}\n      onPointerLeave={() => setPressed(false)}\n      style={{\n        transform: pressed ? \"scale(0.97)\" : undefined,\n        transition: reduced ? \"none\" : `transform 140ms ${EASE}`,\n        ...style,\n      }}\n      className={`relative inline-flex items-center justify-center font-medium rounded-full cursor-pointer select-none transition-colors duration-200 disabled:cursor-not-allowed disabled:opacity-45 ${s.height} ${toneClass} ${INTERACTION_FOCUS} ${className}`}\n      {...props}\n    >\n      {/* Dynamic Leading Icon Slot */}\n      <span\n        aria-hidden=\"true\"\n        className=\"relative inline-flex shrink-0 items-center justify-center overflow-hidden\"\n        style={{\n          width: showLeading ? s.iconSize : 0,\n          marginRight: showLeading ? 8 : 0,\n          transition: reduced ? \"none\" : `width 280ms ${EASE}, margin-right 280ms ${EASE}`,\n        }}\n      >\n        {status === \"pending\" && (\n          <span\n            className={`inline-flex animate-spin rounded-full border-[1.5px] border-current border-t-transparent ${s.spinnerSize}`}\n          />\n        )}\n        {status === \"success\" && (\n          <SelfDrawingCheck drawn={true} reduced={reduced} size={s.iconSize} />\n        )}\n        {status === \"error\" && <StatusAlert size={s.iconSize} />}\n        {status === \"idle\" && icon && (\n          <span className=\"inline-flex size-full items-center justify-center\">{icon}</span>\n        )}\n      </span>\n\n      {/* Morphing Label */}\n      <TextMorph className=\"whitespace-nowrap\">\n        {currentLabel}\n      </TextMorph>\n    </button>\n  );\n}\n\nexport default ActionButton;\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/action-button. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}