{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "morph-button",
  "title": "Morph Button",
  "description": "A multi-state async action button that morphs between idle, loading, success and error with an arc spinner, drawn-in check, tight error shake and auto-reset",
  "dependencies": [
    "framer-motion"
  ],
  "files": [
    {
      "path": "app/registry/morph-button/morph-button.tsx",
      "content": "/**\n * Spectrum UI — MorphButton\n *\n * A multi-state async action button. Clicking runs your action while the pill\n * morphs its width to fit each state's content: a rotating arc spinner while\n * loading, an emerald check that draws in on success, or a rose X with a tight\n * shake on error — then it melts back to idle. Works controlled or\n * uncontrolled, honors prefers-reduced-motion, and announces every state to\n * screen readers via a polite live region.\n *\n * Dependencies: framer-motion, @/lib/utils\n *\n * @example\n * <MorphButton onAction={() => saveChanges()}>Save changes</MorphButton>\n */\n\n\"use client\"\n\nimport React, { useCallback, useEffect, useRef, useState } from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"framer-motion\"\nimport { cn } from \"@/lib/utils\"\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\nexport type MorphButtonState = \"idle\" | \"loading\" | \"success\" | \"error\"\n\nexport interface MorphButtonProps {\n  /** Idle content of the button — usually a short action label */\n  children: React.ReactNode\n  /**\n   * Async work to run on click when uncontrolled. The button shows loading\n   * while the promise is pending, success when it resolves, error when it\n   * throws, then auto-resets to idle after resetDelay\n   */\n  onAction?: () => Promise<void> | void\n  /**\n   * Controlled state. When provided the internal machine is bypassed and the\n   * button renders exactly this state\n   */\n  state?: MorphButtonState\n  /** Click handler; fires on idle clicks in both modes */\n  onClick?: React.MouseEventHandler<HTMLButtonElement>\n  /** Label rendered next to the spinner while loading; spinner-only if omitted */\n  loadingLabel?: string\n  /** Label shown next to the check in the success state. Default \"Done\" */\n  successLabel?: string\n  /** Label shown next to the X in the error state. Default \"Failed\" */\n  errorLabel?: string\n  /** Milliseconds success/error is held before auto-resetting. Default 1800 */\n  resetDelay?: number\n  /** Visual size of the button. Default \"md\" */\n  size?: \"sm\" | \"md\" | \"lg\"\n  /** Disables pointer and keyboard interaction */\n  disabled?: boolean\n  className?: string\n}\n\n// ─── Constants ───────────────────────────────────────────────────────────────\n\n/** Snappy spring for micro moves: width morph, tap scale */\nconst SPRING_SNAPPY = { type: \"spring\", stiffness: 500, damping: 30 } as const\n\n/** Softer spring for the success icon's subtle overshoot pop */\nconst SPRING_SOFT = { type: \"spring\", stiffness: 260, damping: 22 } as const\n\n/** Reveal ease for content slides and icon draw-ins */\nconst EASE_REVEAL: [number, number, number, number] = [0.22, 1, 0.36, 1]\n\n/** Vertical travel (px) of content crossfading between states */\nconst CONTENT_SHIFT = 8\n\n/** Duration (s) of the content slide between states */\nconst CONTENT_DURATION = 0.3\n\n/** Duration (s) of the check/X stroke draw-in */\nconst DRAW_DURATION = 0.25\n\n/** Tight error shake: ≤ 4px, no bounce */\nconst SHAKE_KEYFRAMES = [0, -3, 3, -2, 2, 0]\n\n/** Duration (s) of the error shake */\nconst SHAKE_DURATION = 0.25\n\n/** Seconds per spinner revolution */\nconst SPIN_DURATION = 0.8\n\n/** Fraction of the spinner circle shown as the arc */\nconst SPINNER_ARC = 0.75\n\n/** Spinner circle radius within its 24px viewBox */\nconst SPINNER_RADIUS = 10\n\n/** Default hold (ms) on success/error before auto-reset */\nconst DEFAULT_RESET_DELAY = 1800\n\nconst SIZES = {\n  sm: { button: \"h-8 px-3 text-xs\", content: \"gap-1.5\", icon: 14 },\n  md: { button: \"h-10 px-4 text-sm\", content: \"gap-2\", icon: 16 },\n  lg: { button: \"h-12 px-5 text-base\", content: \"gap-2\", icon: 18 },\n} as const\n\nconst CHECK_PATH = \"M5 13l4.5 4.5L19 7\"\nconst X_PATHS = [\"M7 7l10 10\", \"M17 7L7 17\"]\n\nconst STATE_CLASSES: Record<MorphButtonState, string> = {\n  idle: \"bg-neutral-900 text-white hover:bg-neutral-700 dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-200\",\n  loading:\n    \"bg-neutral-900 text-white dark:bg-white dark:text-neutral-900\",\n  success: \"bg-emerald-500 text-white\",\n  error: \"bg-rose-500 text-white\",\n}\n\n// ─── Icons ───────────────────────────────────────────────────────────────────\n\nfunction SpinnerIcon({ size }: { size: number }) {\n  const circumference = 2 * Math.PI * SPINNER_RADIUS\n  return (\n    <motion.svg\n      viewBox=\"0 0 24 24\"\n      width={size}\n      height={size}\n      fill=\"none\"\n      aria-hidden=\"true\"\n      animate={{ rotate: 360 }}\n      transition={{ duration: SPIN_DURATION, ease: \"linear\", repeat: Infinity }}\n    >\n      <circle\n        cx=\"12\"\n        cy=\"12\"\n        r={SPINNER_RADIUS}\n        stroke=\"currentColor\"\n        strokeWidth=\"2.5\"\n        strokeLinecap=\"round\"\n        strokeDasharray={`${circumference * SPINNER_ARC} ${circumference}`}\n      />\n    </motion.svg>\n  )\n}\n\nfunction DrawnIcon({\n  paths,\n  size,\n  instant,\n}: {\n  paths: string[]\n  size: number\n  instant: boolean\n}) {\n  return (\n    <svg\n      viewBox=\"0 0 24 24\"\n      width={size}\n      height={size}\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2.5\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden=\"true\"\n    >\n      {paths.map((d, index) => (\n        <motion.path\n          key={d}\n          d={d}\n          initial={{ pathLength: 0 }}\n          animate={{ pathLength: 1 }}\n          transition={\n            instant\n              ? { duration: 0 }\n              : {\n                  duration: DRAW_DURATION,\n                  ease: EASE_REVEAL,\n                  delay: index * 0.05,\n                }\n          }\n        />\n      ))}\n    </svg>\n  )\n}\n\n// ─── Component ───────────────────────────────────────────────────────────────\n\nexport function MorphButton({\n  children,\n  onAction,\n  state: stateProp,\n  onClick,\n  loadingLabel,\n  successLabel = \"Done\",\n  errorLabel = \"Failed\",\n  resetDelay = DEFAULT_RESET_DELAY,\n  size = \"md\",\n  disabled = false,\n  className,\n}: MorphButtonProps) {\n  const shouldReduceMotion = useReducedMotion()\n  const [internalState, setInternalState] = useState<MorphButtonState>(\"idle\")\n  const mountedRef = useRef(true)\n\n  const isControlled = stateProp !== undefined\n  const state = stateProp ?? internalState\n  const interactive = state === \"idle\" && !disabled\n  const { button: sizeClasses, content: contentGap, icon: iconSize } =\n    SIZES[size]\n\n  useEffect(() => {\n    mountedRef.current = true\n    return () => {\n      mountedRef.current = false\n    }\n  }, [])\n\n  // Auto-reset the internal machine; cleanup clears the timer on unmount or\n  // when the state moves on before the delay elapses\n  useEffect(() => {\n    if (isControlled) return\n    if (internalState !== \"success\" && internalState !== \"error\") return\n    const timer = setTimeout(() => setInternalState(\"idle\"), resetDelay)\n    return () => clearTimeout(timer)\n  }, [internalState, isControlled, resetDelay])\n\n  const handleClick = useCallback(\n    async (event: React.MouseEvent<HTMLButtonElement>) => {\n      if (!interactive) return\n      onClick?.(event)\n      if (isControlled || !onAction) return\n      setInternalState(\"loading\")\n      try {\n        await onAction()\n        if (mountedRef.current) setInternalState(\"success\")\n      } catch {\n        if (mountedRef.current) setInternalState(\"error\")\n      }\n    },\n    [interactive, isControlled, onAction, onClick],\n  )\n\n  const announcement =\n    state === \"loading\"\n      ? loadingLabel ?? \"Loading\"\n      : state === \"success\"\n        ? successLabel\n        : state === \"error\"\n          ? errorLabel\n          : \"\"\n\n  let content: React.ReactNode\n  if (state === \"loading\") {\n    content = (\n      <>\n        <SpinnerIcon size={iconSize} />\n        {loadingLabel && <span>{loadingLabel}</span>}\n      </>\n    )\n  } else if (state === \"success\") {\n    content = (\n      <>\n        <motion.span\n          className=\"inline-flex\"\n          initial={shouldReduceMotion ? false : { scale: 0.6 }}\n          animate={{ scale: 1 }}\n          transition={SPRING_SOFT}\n        >\n          <DrawnIcon\n            paths={[CHECK_PATH]}\n            size={iconSize}\n            instant={!!shouldReduceMotion}\n          />\n        </motion.span>\n        {successLabel && <span>{successLabel}</span>}\n      </>\n    )\n  } else if (state === \"error\") {\n    content = (\n      <>\n        <DrawnIcon paths={X_PATHS} size={iconSize} instant={!!shouldReduceMotion} />\n        {errorLabel && <span>{errorLabel}</span>}\n      </>\n    )\n  } else {\n    content = children\n  }\n\n  return (\n    <motion.button\n      type=\"button\"\n      layout\n      onClick={handleClick}\n      disabled={disabled}\n      aria-disabled={!interactive || undefined}\n      aria-busy={state === \"loading\" || undefined}\n      aria-label={typeof children === \"string\" ? children : undefined}\n      style={{ borderRadius: 999 }}\n      whileTap={interactive && !shouldReduceMotion ? { scale: 0.97 } : undefined}\n      animate={\n        state === \"error\" && !shouldReduceMotion\n          ? { x: SHAKE_KEYFRAMES }\n          : { x: 0 }\n      }\n      transition={{\n        layout: shouldReduceMotion ? { duration: 0 } : SPRING_SNAPPY,\n        scale: SPRING_SNAPPY,\n        x: { duration: SHAKE_DURATION, ease: \"easeInOut\" },\n      }}\n      className={cn(\n        \"relative inline-flex select-none items-center justify-center overflow-hidden rounded-full font-medium\",\n        \"transition-colors duration-300\",\n        \"focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 dark:focus-visible:ring-neutral-300\",\n        \"disabled:pointer-events-none disabled:opacity-50\",\n        !interactive && \"pointer-events-none\",\n        STATE_CLASSES[state],\n        sizeClasses,\n        className,\n      )}\n    >\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        <motion.span\n          key={state}\n          className={cn(\n            \"inline-flex items-center justify-center whitespace-nowrap\",\n            contentGap,\n          )}\n          initial={{ y: CONTENT_SHIFT, opacity: 0 }}\n          animate={{ y: 0, opacity: 1 }}\n          exit={{ y: -CONTENT_SHIFT, opacity: 0 }}\n          transition={\n            shouldReduceMotion\n              ? { duration: 0 }\n              : { duration: CONTENT_DURATION, ease: EASE_REVEAL }\n          }\n        >\n          {content}\n        </motion.span>\n      </AnimatePresence>\n      <span aria-live=\"polite\" role=\"status\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </motion.button>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/spectrumui/morph-button.tsx"
    }
  ],
  "type": "registry:component"
}
