{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"morphing-search","type":"registry:component","title":"Morphing Search","description":"A circular search button that morphs into a text field: the same element grows and reshapes via a layout animation, rather than a new one popping in beside it.","author":"Ryan","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/morphing-search.tsx","type":"registry:component","target":"@components/motion/morphing-search.tsx","content":"\"use client\";\n// easeui.dev/components/motion/morphing-search\n\nimport { Search, X } from \"lucide-react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { SPRING_PANEL } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface MorphingSearchProps {\n  placeholder?: string;\n  onSearch?: (query: string) => void;\n  className?: string;\n}\n\n/**\n * A circular search button that morphs into a text field: the same element\n * grows and reshapes via a layout animation, rather than a new one popping\n * in beside it. Closes on Escape, on submit, or on an outside click.\n */\nexport function MorphingSearch({ placeholder = \"Search...\", onSearch, className }: MorphingSearchProps) {\n  const [open, setOpen] = useState(false);\n  const [query, setQuery] = useState(\"\");\n  const inputRef = useRef<HTMLInputElement>(null);\n  const rootRef = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    if (open) inputRef.current?.focus();\n  }, [open]);\n\n  useEffect(() => {\n    if (!open) return;\n    const onPointerDown = (event: PointerEvent) => {\n      if (!rootRef.current?.contains(event.target as Node)) setOpen(false);\n    };\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") setOpen(false);\n    };\n    window.addEventListener(\"pointerdown\", onPointerDown);\n    window.addEventListener(\"keydown\", onKeyDown);\n    return () => {\n      window.removeEventListener(\"pointerdown\", onPointerDown);\n      window.removeEventListener(\"keydown\", onKeyDown);\n    };\n  }, [open]);\n\n  const close = () => {\n    setOpen(false);\n    setQuery(\"\");\n  };\n\n  return (\n    <div ref={rootRef} className={cn(\"inline-flex\", className)}>\n      <motion.div\n        layout\n        transition={SPRING_PANEL}\n        style={{ borderRadius: 9999 }}\n        className=\"flex h-11 items-center overflow-hidden bg-card shadow-[0_0_0_1px_var(--border)]\"\n      >\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {open ? (\n            <motion.form\n              key=\"form\"\n              layout\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1, transition: { delay: 0.1, duration: 0.15 } }}\n              exit={{ opacity: 0, transition: { duration: 0.1 } }}\n              onSubmit={(event) => {\n                event.preventDefault();\n                onSearch?.(query);\n              }}\n              className=\"flex items-center gap-1 pl-4 pr-1.5\"\n            >\n              <Search aria-hidden=\"true\" className=\"h-4 w-4 shrink-0 text-muted-foreground\" />\n              <input\n                ref={inputRef}\n                value={query}\n                onChange={(event) => setQuery(event.target.value)}\n                placeholder={placeholder}\n                className=\"h-11 w-56 min-w-0 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground\"\n              />\n              <button\n                type=\"button\"\n                aria-label=\"Close search\"\n                onClick={close}\n                className=\"relative flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-muted-foreground outline-none after:absolute after:-inset-1 transition-colors duration-150 hover:bg-muted hover:text-foreground\"\n              >\n                <X className=\"h-4 w-4\" />\n              </button>\n            </motion.form>\n          ) : (\n            <motion.button\n              key=\"trigger\"\n              layout\n              type=\"button\"\n              aria-label=\"Search\"\n              onClick={() => setOpen(true)}\n              // `layout` keeps the icon from stretching as the shell resizes\n              // around it; the delay also hides it until the shell has\n              // mostly finished shrinking, so it never appears mid-squash.\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1, transition: { delay: 0.18, duration: 0.1 } }}\n              exit={{ opacity: 0, transition: { duration: 0 } }}\n              className=\"flex h-11 w-11 shrink-0 items-center justify-center text-muted-foreground transition-colors duration-150 hover:text-foreground\"\n            >\n              <Search className=\"h-[18px] w-[18px]\" />\n            </motion.button>\n          )}\n        </AnimatePresence>\n      </motion.div>\n    </div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Micro-interactions run 100 to 150ms, standard UI\n// 150 to 250ms, and panels up to 300ms.\n// Easing curves mirror the CSS custom properties in globals.css.\n\n/** ease-out-quint. Fast start that settles quickly. Entrances, exits, feedback. */\nexport const EASE_OUT = [0.23, 1, 0.32, 1] as const;\n/** ease-in-out-cubic. Elements already on screen moving to a new spot. */\nexport const EASE_IN_OUT = [0.645, 0.045, 0.355, 1] as const;\n/** Sheet and drawer glide. */\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.23, 1, 0.32, 1)\";\n\n// Springs are described by duration and bounce, which is easier to reason about\n// than stiffness and damping. Bounce stays at zero for product UI.\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  duration: 0.15,\n  bounce: 0,\n} as const;\n\n/** Content swaps, label and icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  duration: 0.2,\n  bounce: 0,\n} as const;\n\n/** Overlay panel entrances, modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  duration: 0.25,\n  bounce: 0,\n} as const;\n\n/** Shared-layout glides, pills and indicators moving between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  duration: 0.22,\n  bounce: 0,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt). */\nexport const SPRING_MOUSE = {\n  stiffness: 320,\n  damping: 26,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders). Critically damped `useSpring` config,\n * so the value follows the pointer closely and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}