{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["class-variance-authority","motion"],"description":"Feedback dock that morphs from a compact pill into a panel and back, with a spring layout transition.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport SmoothButton from \"@/components/smoothui/smooth-button\";\nimport { cx } from \"class-variance-authority\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport React from \"react\";\nimport SiriOrb from \"../siri-orb\";\nimport { useClickOutside } from \"./use-click-outside\";\n\nconst SPEED = 1;\nconst SUCCESS_DURATION = 1500;\nconst DOCK_HEIGHT = 44;\nconst FEEDBACK_BORDER_RADIUS = 14;\nconst DOCK_BORDER_RADIUS = 20;\nconst SPRING_STIFFNESS = 550;\nconst SPRING_DAMPING = 45;\nconst SPRING_MASS = 0.7;\nconst CLOSE_DELAY = 0.08;\n\ninterface FooterContext {\n  closeFeedback: () => void;\n  openFeedback: () => void;\n  showFeedback: boolean;\n  success: boolean;\n}\n\nconst FooterContext = React.createContext({} as FooterContext);\nconst useFooter = () => React.useContext(FooterContext);\n\nexport function MorphSurface() {\n  const rootRef = React.useRef<HTMLDivElement>(null);\n\n  const feedbackRef = React.useRef<HTMLTextAreaElement | null>(null);\n  const [showFeedback, setShowFeedback] = React.useState(false);\n  const [success, setSuccess] = React.useState(false);\n  const shouldReduceMotion = useReducedMotion();\n\n  const closeFeedback = React.useCallback(() => {\n    setShowFeedback(false);\n    feedbackRef.current?.blur();\n  }, []);\n\n  const openFeedback = React.useCallback(() => {\n    setShowFeedback(true);\n    setTimeout(() => {\n      feedbackRef.current?.focus();\n    });\n  }, []);\n\n  const onFeedbackSuccess = React.useCallback(() => {\n    closeFeedback();\n    setSuccess(true);\n    setTimeout(() => {\n      setSuccess(false);\n    }, SUCCESS_DURATION);\n  }, [closeFeedback]);\n\n  useClickOutside(rootRef, closeFeedback);\n\n  const context = React.useMemo(\n    () => ({\n      closeFeedback,\n      openFeedback,\n      showFeedback,\n      success,\n    }),\n    [showFeedback, success, openFeedback, closeFeedback]\n  );\n\n  return (\n    <div\n      className=\"flex items-center justify-center\"\n      style={{\n        height: FEEDBACK_HEIGHT,\n        width: FEEDBACK_WIDTH,\n      }}\n    >\n      <motion.div\n        animate={\n          shouldReduceMotion\n            ? {}\n            : {\n                borderRadius: showFeedback\n                  ? FEEDBACK_BORDER_RADIUS\n                  : DOCK_BORDER_RADIUS,\n                height: showFeedback ? FEEDBACK_HEIGHT : DOCK_HEIGHT,\n                width: showFeedback ? FEEDBACK_WIDTH : \"auto\",\n              }\n        }\n        className={cx(\n          \"relative bottom-8 z-3 flex flex-col items-center overflow-hidden border bg-background max-sm:bottom-5\"\n        )}\n        data-footer\n        initial={false}\n        ref={rootRef}\n        transition={\n          shouldReduceMotion\n            ? { duration: 0 }\n            : {\n                damping: SPRING_DAMPING,\n                delay: showFeedback ? 0 : CLOSE_DELAY,\n                duration: 0.25,\n                mass: SPRING_MASS,\n                stiffness: SPRING_STIFFNESS / SPEED,\n                type: \"spring\" as const,\n              }\n        }\n      >\n        <FooterContext.Provider value={context}>\n          <Dock />\n          <Feedback onSuccess={onFeedbackSuccess} ref={feedbackRef} />\n        </FooterContext.Provider>\n      </motion.div>\n    </div>\n  );\n}\n\nfunction Dock() {\n  const { showFeedback, openFeedback } = useFooter();\n  const shouldReduceMotion = useReducedMotion();\n  return (\n    <footer className=\"mt-auto flex h-[44px] select-none items-center justify-center whitespace-nowrap\">\n      <div className=\"flex items-center justify-center gap-2 px-3 max-sm:h-10 max-sm:px-2\">\n        <div className=\"flex w-fit items-center gap-2\">\n          <AnimatePresence mode=\"wait\">\n            {showFeedback ? (\n              <motion.div\n                animate={shouldReduceMotion ? {} : { opacity: 0 }}\n                className=\"h-5 w-5\"\n                exit={shouldReduceMotion ? {} : { opacity: 0 }}\n                initial={shouldReduceMotion ? {} : { opacity: 0 }}\n                key=\"placeholder\"\n                transition={\n                  shouldReduceMotion ? { duration: 0 } : { duration: 0.2 }\n                }\n              />\n            ) : (\n              <motion.div\n                animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1 }}\n                exit={\n                  shouldReduceMotion\n                    ? { opacity: 0, transition: { duration: 0 } }\n                    : { opacity: 0 }\n                }\n                initial={shouldReduceMotion ? { opacity: 1 } : { opacity: 0 }}\n                key=\"siri-orb\"\n                transition={\n                  shouldReduceMotion ? { duration: 0 } : { duration: 0.2 }\n                }\n              >\n                <SiriOrb\n                  colors={{\n                    bg: \"oklch(22.64% 0 0)\",\n                  }}\n                  size=\"24px\"\n                />\n              </motion.div>\n            )}\n          </AnimatePresence>\n        </div>\n\n        <SmoothButton\n          className=\"flex h-fit flex-1 justify-end rounded-full px-2 py-0.5!\"\n          onClick={openFeedback}\n          type=\"button\"\n          variant=\"ghost\"\n        >\n          <span className=\"truncate\">Ask AI</span>\n        </SmoothButton>\n      </div>\n    </footer>\n  );\n}\n\nconst FEEDBACK_WIDTH = 360;\nconst FEEDBACK_HEIGHT = 200;\n\nfunction Feedback({\n  ref,\n  onSuccess,\n}: {\n  ref: React.Ref<HTMLTextAreaElement>;\n  onSuccess: () => void;\n}) {\n  const { closeFeedback, showFeedback } = useFooter();\n  const shouldReduceMotion = useReducedMotion();\n  const submitRef = React.useRef<HTMLButtonElement>(null);\n\n  function onSubmit(e: React.FormEvent<HTMLFormElement>) {\n    e.preventDefault();\n    onSuccess();\n  }\n\n  function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {\n    if (e.key === \"Escape\") {\n      closeFeedback();\n    }\n    if (e.key === \"Enter\" && e.metaKey) {\n      e.preventDefault();\n      submitRef.current?.click();\n    }\n  }\n\n  return (\n    <form\n      className=\"absolute bottom-0\"\n      onSubmit={onSubmit}\n      style={{\n        height: FEEDBACK_HEIGHT,\n        pointerEvents: showFeedback ? \"all\" : \"none\",\n        width: FEEDBACK_WIDTH,\n      }}\n    >\n      <AnimatePresence>\n        {showFeedback ? (\n          <motion.div\n            animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1 }}\n            className=\"flex h-full flex-col p-1\"\n            exit={\n              shouldReduceMotion\n                ? { opacity: 0, transition: { duration: 0 } }\n                : { opacity: 0 }\n            }\n            initial={shouldReduceMotion ? { opacity: 1 } : { opacity: 0 }}\n            transition={\n              shouldReduceMotion\n                ? { duration: 0 }\n                : {\n                    damping: SPRING_DAMPING,\n                    duration: 0.25,\n                    mass: SPRING_MASS,\n                    stiffness: SPRING_STIFFNESS / SPEED,\n                    type: \"spring\" as const,\n                  }\n            }\n          >\n            <div className=\"flex justify-between py-1\">\n              <p className=\"z-2 ml-[38px] flex select-none items-center gap-[6px] text-foreground\">\n                AI Input\n              </p>\n              <button\n                className=\"right-4 mt-1 flex -translate-y-[3px] cursor-pointer select-none items-center justify-center gap-1 rounded-[12px] bg-transparent pr-1 text-center text-foreground\"\n                ref={submitRef}\n                type=\"submit\"\n              >\n                <Kbd>⌘</Kbd>\n                <Kbd className=\"w-fit\">Enter</Kbd>\n              </button>\n            </div>\n            <textarea\n              className=\"h-full w-full resize-none scroll-py-2 rounded-md bg-primary p-4 outline-0\"\n              name=\"message\"\n              onKeyDown={onKeyDown}\n              placeholder=\"Ask me anything...\"\n              ref={ref}\n              required\n              spellCheck={false}\n            />\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n      <AnimatePresence>\n        {showFeedback ? (\n          <motion.div\n            animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1 }}\n            className=\"absolute top-2 left-3\"\n            exit={\n              shouldReduceMotion\n                ? { opacity: 0, transition: { duration: 0 } }\n                : { opacity: 0 }\n            }\n            initial={shouldReduceMotion ? { opacity: 1 } : { opacity: 0 }}\n            transition={\n              shouldReduceMotion ? { duration: 0 } : { duration: 0.2 }\n            }\n          >\n            <SiriOrb\n              colors={{\n                bg: \"oklch(22.64% 0 0)\",\n              }}\n              size=\"24px\"\n            />\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n    </form>\n  );\n}\n\nfunction Kbd({\n  children,\n  className,\n}: {\n  children: string;\n  className?: string;\n}) {\n  return (\n    <kbd\n      className={cx(\n        \"flex h-6 w-fit items-center justify-center rounded-sm border bg-primary px-[6px] font-sans text-foreground\",\n        className\n      )}\n    >\n      {children}\n    </kbd>\n  );\n}\n\n// Add default export for lazy loading\nexport default MorphSurface;\n","path":"index.tsx","target":"components/smoothui/morph-surface/index.tsx","type":"registry:ui"},{"content":"\"use client\";\n\nimport { type RefObject, useEffect } from \"react\";\n\ntype EventType =\n  | \"mousedown\"\n  | \"mouseup\"\n  | \"touchstart\"\n  | \"touchend\"\n  | \"focusin\"\n  | \"focusout\";\n\nexport function useClickOutside<T extends HTMLElement = HTMLElement>(\n  ref: RefObject<T | null> | RefObject<T | null>[],\n  handler: (event: Event) => void,\n  eventType: EventType = \"mousedown\"\n): void {\n  useEffect(() => {\n    function callback(event: Event) {\n      const target = event.target as Node;\n\n      // Do nothing if the target is not connected element with document\n      if (!target?.isConnected) {\n        return;\n      }\n\n      const isOutside = Array.isArray(ref)\n        ? ref\n            .filter((r) => Boolean(r.current))\n            .every((r) => r.current && !r.current.contains(target))\n        : ref.current && !ref.current.contains(target);\n\n      if (isOutside) {\n        handler(event);\n      }\n    }\n\n    window.addEventListener(eventType, callback);\n\n    return () => {\n      window.removeEventListener(eventType, callback);\n    };\n  }, [eventType, handler, ref]);\n}\n","path":"use-click-outside.tsx","target":"components/smoothui/morph-surface/use-click-outside.tsx","type":"registry:ui"}],"name":"morph-surface","registryDependencies":["https://smoothui.dev/r/smooth-button.json","https://smoothui.dev/r/siri-orb.json"],"title":"Morph Surface","type":"registry:ui"}