{
  "name": "morph-popover",
  "type": "registry:component",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Morph Popover\n * @version: 1.0.0\n * @date: 2026-10-15\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport {\n  AnimatePresence,\n  LayoutGroup,\n  MotionConfig,\n  motion,\n  type Transition,\n} from \"motion/react\";\nimport {\n  type FormEvent,\n  type KeyboardEvent,\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface MorphPopoverProps {\n  /** The button's label, which slides up to become the panel's title. */\n  label?: string;\n  icon?: ReactNode;\n  placeholder?: string;\n  /** Shown in the small pill after sending. */\n  sentLabel?: string;\n  /** Called with the text. A promise keeps the panel in its sending state. */\n  onSubmit?: (value: string) => void | Promise<void>;\n  /** Which edge the panel grows from. */\n  align?: \"start\" | \"end\";\n  /** Panel width in px. */\n  width?: number;\n  className?: string;\n}\n\ntype Phase = \"idle\" | \"open\" | \"sending\" | \"sent\";\n\nconst MORPH: Transition = { type: \"spring\", duration: 0.42, bounce: 0.14 };\nconst FADE: Transition = { duration: 0.2, ease: [0.23, 1, 0.32, 1] };\nconst SENT_FOR = 1600;\n// Stands in for a request so the demo shows its sending state.\nconst pretendSend = () =>\n  new Promise<void>((resolve) => {\n    window.setTimeout(resolve, 700);\n  });\nconst BUTTON_HEIGHT = 36;\nconst APPLE_PLATFORM = /Mac|iPhone|iPad/;\nconst isMac = () =>\n  typeof navigator !== \"undefined\" && APPLE_PLATFORM.test(navigator.platform);\n\nfunction ChatIcon() {\n  return (\n    <svg aria-hidden=\"true\" className=\"size-4\" fill=\"none\" viewBox=\"0 0 16 16\">\n      <path\n        d=\"M2.75 4.25a1.5 1.5 0 0 1 1.5-1.5h7.5a1.5 1.5 0 0 1 1.5 1.5v5a1.5 1.5 0 0 1-1.5 1.5H7l-2.75 2.5v-2.5a1.5 1.5 0 0 1-1.5-1.5v-5Z\"\n        stroke=\"currentColor\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"1.4\"\n      />\n    </svg>\n  );\n}\n\nfunction Spinner() {\n  return (\n    <svg\n      aria-hidden=\"true\"\n      className=\"size-3.5 animate-spin motion-reduce:animate-none\"\n      fill=\"none\"\n      viewBox=\"0 0 16 16\"\n    >\n      <circle\n        cx=\"8\"\n        cy=\"8\"\n        r=\"6\"\n        stroke=\"currentColor\"\n        strokeOpacity=\"0.25\"\n        strokeWidth=\"2\"\n      />\n      <path\n        d=\"M14 8a6 6 0 0 0-6-6\"\n        stroke=\"currentColor\"\n        strokeLinecap=\"round\"\n        strokeWidth=\"2\"\n      />\n    </svg>\n  );\n}\n\n/**\n * A button that grows into the small form it opens, and shrinks into a\n * confirmation once sent. The label travels with it and becomes the title.\n */\nexport default function MorphPopover({\n  label = \"Feedback\",\n  icon = <ChatIcon />,\n  placeholder = \"What would make this better?\",\n  sentLabel = \"Thanks!\",\n  onSubmit = pretendSend,\n  align = \"start\",\n  width = 340,\n  className,\n}: MorphPopoverProps) {\n  const id = useId();\n  const [phase, setPhase] = useState<Phase>(\"idle\");\n  const [value, setValue] = useState(\"\");\n  const [shortcut, setShortcut] = useState(\"Ctrl\");\n  const [size, setSize] = useState<{ idle: number; sent: number } | null>(null);\n  const rootRef = useRef<HTMLDivElement>(null);\n  const textareaRef = useRef<HTMLTextAreaElement>(null);\n  const idleSizer = useRef<HTMLSpanElement>(null);\n  const sentSizer = useRef<HTMLSpanElement>(null);\n  const wasOpen = useRef(false);\n  const expanded = phase === \"open\" || phase === \"sending\";\n\n  useEffect(() => {\n    if (isMac()) {\n      setShortcut(\"⌘\");\n    }\n  }, []);\n\n  // The closed shapes are as wide as their content, measured off-screen.\n  useLayoutEffect(() => {\n    const measure = () =>\n      setSize({\n        idle: idleSizer.current?.offsetWidth ?? 120,\n        sent: sentSizer.current?.offsetWidth ?? 100,\n      });\n    measure();\n    const observer = new ResizeObserver(measure);\n    if (idleSizer.current) {\n      observer.observe(idleSizer.current);\n    }\n    if (sentSizer.current) {\n      observer.observe(sentSizer.current);\n    }\n    return () => observer.disconnect();\n  }, []);\n\n  // Focus follows the shape: into the field on open, back to the button after.\n  useEffect(() => {\n    if (phase === \"open\") {\n      textareaRef.current?.focus({ preventScroll: true });\n      wasOpen.current = true;\n    } else if (phase === \"idle\" && wasOpen.current) {\n      wasOpen.current = false;\n      // Found by query: refs on AnimatePresence children trip React 19 warnings.\n      rootRef.current\n        ?.querySelector<HTMLButtonElement>(\"button[aria-haspopup]\")\n        ?.focus({ preventScroll: true });\n    }\n  }, [phase]);\n\n  useEffect(() => {\n    if (phase !== \"sent\") {\n      return;\n    }\n    const timer = window.setTimeout(() => setPhase(\"idle\"), SENT_FOR);\n    return () => window.clearTimeout(timer);\n  }, [phase]);\n\n  useEffect(() => {\n    if (phase !== \"open\") {\n      return;\n    }\n    const close = (event: PointerEvent) => {\n      if (!rootRef.current?.contains(event.target as Node)) {\n        setPhase(\"idle\");\n      }\n    };\n    document.addEventListener(\"pointerdown\", close);\n    return () => document.removeEventListener(\"pointerdown\", close);\n  }, [phase]);\n\n  const submit = useCallback(\n    async (event?: FormEvent) => {\n      event?.preventDefault();\n      const text = value.trim();\n      if (!text || phase !== \"open\") {\n        return;\n      }\n      setPhase(\"sending\");\n      try {\n        await onSubmit(text);\n        setValue(\"\");\n        setPhase(\"sent\");\n      } catch {\n        setPhase(\"open\");\n      }\n    },\n    [onSubmit, phase, value]\n  );\n\n  const handleKeyDown = useCallback(\n    (event: KeyboardEvent) => {\n      if (event.key === \"Escape\" && phase === \"open\") {\n        event.stopPropagation();\n        setPhase(\"idle\");\n      }\n      if (event.key === \"Enter\" && (event.metaKey || event.ctrlKey)) {\n        event.preventDefault();\n        submit();\n      }\n    },\n    [phase, submit]\n  );\n\n  let target: { width: number | string; height: number | string };\n  if (expanded) {\n    target = { width, height: \"auto\" };\n  } else if (phase === \"sent\") {\n    target = { width: size?.sent ?? \"auto\", height: BUTTON_HEIGHT };\n  } else {\n    target = { width: size?.idle ?? \"auto\", height: BUTTON_HEIGHT };\n  }\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n      <LayoutGroup id={id}>\n        <div\n          className={cn(\"relative inline-block align-top\", className)}\n          ref={rootRef}\n          style={{ height: BUTTON_HEIGHT }}\n        >\n          {/* Off-screen copies that give the closed shapes their widths. */}\n          <span\n            aria-hidden=\"true\"\n            className=\"pointer-events-none invisible absolute flex items-center gap-2 whitespace-nowrap px-3.5 font-medium text-sm\"\n            ref={idleSizer}\n          >\n            <span className=\"size-4\" />\n            {label}\n          </span>\n          <span\n            aria-hidden=\"true\"\n            className=\"pointer-events-none invisible absolute flex items-center gap-2 whitespace-nowrap px-3.5 font-medium text-sm\"\n            ref={sentSizer}\n          >\n            <span className=\"size-4\" />\n            {sentLabel}\n          </span>\n          {/* Keeps the inline box button-sized while the panel floats over the page. */}\n          <span\n            aria-hidden=\"true\"\n            className=\"invisible block\"\n            style={{ width: size?.idle ?? 120 }}\n          />\n          <motion.div\n            animate={{\n              ...target,\n              borderRadius: expanded ? 16 : BUTTON_HEIGHT / 2,\n            }}\n            className={cn(\n              \"absolute top-0 z-20 overflow-hidden border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100\",\n              align === \"start\" ? \"left-0\" : \"right-0\",\n              expanded\n                ? \"shadow-xl shadow-zinc-950/10 dark:shadow-black/40\"\n                : \"shadow-sm shadow-zinc-950/5\",\n              // The shell clips the button, so its focus ring is drawn here.\n              phase === \"idle\" &&\n                \"has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-zinc-400 has-[:focus-visible]:ring-offset-2 dark:has-[:focus-visible]:ring-offset-zinc-950\"\n            )}\n            initial={false}\n            onKeyDown={handleKeyDown}\n            transition={MORPH}\n          >\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              {phase === \"idle\" ? (\n                <motion.button\n                  animate={{ opacity: 1 }}\n                  aria-expanded={false}\n                  aria-haspopup=\"dialog\"\n                  className=\"flex h-[34px] items-center gap-2 whitespace-nowrap px-3.5 font-medium text-sm transition-colors duration-150 hover:bg-zinc-50 focus-visible:outline-none active:bg-zinc-100 dark:active:bg-zinc-700 dark:hover:bg-zinc-800\"\n                  exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                  initial={{ opacity: 0 }}\n                  key=\"idle\"\n                  onClick={() => setPhase(\"open\")}\n                  transition={FADE}\n                  type=\"button\"\n                >\n                  <motion.span\n                    className=\"text-zinc-500 dark:text-zinc-400\"\n                    layoutId=\"icon\"\n                  >\n                    {icon}\n                  </motion.span>\n                  <motion.span layoutId=\"label\">{label}</motion.span>\n                </motion.button>\n              ) : null}\n\n              {expanded ? (\n                <motion.form\n                  animate={{ opacity: 1 }}\n                  aria-label={label}\n                  className=\"flex flex-col\"\n                  exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                  initial={{ opacity: 0 }}\n                  key=\"open\"\n                  onSubmit={submit}\n                  style={{ width: width - 2 }}\n                  transition={FADE}\n                >\n                  <div className=\"flex items-center gap-2 px-3.5 pt-2.5 font-medium text-sm\">\n                    <motion.span\n                      className=\"text-zinc-500 dark:text-zinc-400\"\n                      layoutId=\"icon\"\n                    >\n                      {icon}\n                    </motion.span>\n                    <motion.span layoutId=\"label\">{label}</motion.span>\n                  </div>\n                  <motion.div\n                    animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                    className=\"flex flex-col\"\n                    initial={{ opacity: 0, y: 4, filter: \"blur(4px)\" }}\n                    transition={{ ...FADE, delay: 0.08 }}\n                  >\n                    <label className=\"sr-only\" htmlFor={`${id}-text`}>\n                      {placeholder}\n                    </label>\n                    <textarea\n                      className=\"h-28 w-full resize-none bg-transparent px-3.5 pt-2 text-sm text-zinc-900 leading-relaxed outline-none placeholder:text-zinc-400 disabled:opacity-60 dark:text-zinc-100 dark:placeholder:text-zinc-500\"\n                      disabled={phase === \"sending\"}\n                      id={`${id}-text`}\n                      onChange={(event) => setValue(event.target.value)}\n                      placeholder={placeholder}\n                      ref={textareaRef}\n                      value={value}\n                    />\n                    <div className=\"flex items-center justify-between gap-3 border-zinc-100 border-t bg-zinc-50/70 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-950/40\">\n                      <span className=\"flex items-center gap-1 text-xs text-zinc-400 dark:text-zinc-500\">\n                        <kbd className=\"rounded border border-zinc-200 bg-white px-1 font-sans text-[11px] text-zinc-500 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-400\">\n                          {shortcut}\n                        </kbd>\n                        <kbd className=\"rounded border border-zinc-200 bg-white px-1 font-sans text-[11px] text-zinc-500 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-400\">\n                          ↵\n                        </kbd>\n                        <span className=\"ml-1\">to send</span>\n                      </span>\n                      <button\n                        className=\"flex h-7 items-center gap-1.5 rounded-lg bg-zinc-900 px-3 font-medium text-white text-xs transition-colors duration-150 hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-zinc-300 dark:bg-white dark:text-zinc-900 dark:disabled:bg-zinc-700 dark:disabled:text-zinc-400 dark:hover:bg-zinc-200\"\n                        disabled={!value.trim() || phase === \"sending\"}\n                        type=\"submit\"\n                      >\n                        {phase === \"sending\" ? <Spinner /> : null}\n                        {phase === \"sending\" ? \"Sending\" : \"Send\"}\n                      </button>\n                    </div>\n                  </motion.div>\n                </motion.form>\n              ) : null}\n\n              {phase === \"sent\" ? (\n                <motion.div\n                  animate={{ opacity: 1, filter: \"blur(0px)\" }}\n                  className=\"flex h-[34px] items-center gap-2 whitespace-nowrap px-3.5 font-medium text-sm\"\n                  exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                  initial={{ opacity: 0, filter: \"blur(4px)\" }}\n                  key=\"sent\"\n                  role=\"status\"\n                  transition={{ ...FADE, delay: 0.1 }}\n                >\n                  <svg\n                    aria-hidden=\"true\"\n                    className=\"size-4 text-emerald-500\"\n                    fill=\"none\"\n                    viewBox=\"0 0 16 16\"\n                  >\n                    <circle\n                      cx=\"8\"\n                      cy=\"8\"\n                      fill=\"currentColor\"\n                      opacity=\"0.15\"\n                      r=\"8\"\n                    />\n                    <motion.path\n                      animate={{ pathLength: 1 }}\n                      d=\"M4.75 8.25 7 10.5l4.25-4.75\"\n                      initial={{ pathLength: 0 }}\n                      stroke=\"currentColor\"\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                      strokeWidth=\"1.75\"\n                      transition={{\n                        duration: 0.3,\n                        ease: [0.23, 1, 0.32, 1],\n                        delay: 0.2,\n                      }}\n                    />\n                  </svg>\n                  {sentLabel}\n                </motion.div>\n              ) : null}\n            </AnimatePresence>\n          </motion.div>\n        </div>\n      </LayoutGroup>\n    </MotionConfig>\n  );\n}\n",
      "path": "/components/kokonutui/morph-popover.tsx",
      "target": "components/kokonutui/morph-popover.tsx"
    }
  ]
}