{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "gooey-input",
  "type": "registry:ui",
  "title": "Gooey Input",
  "author": "Rishabh Bose <https://theb0se.vercel.app/>",
  "description": "Liquid search input that dynamically detaches a viscous gooey bubble containing the search icon using an SVG feColorMatrix filter.",
  "dependencies": [
    "framer-motion",
    "lucide-react",
    "clsx",
    "tailwind-merge"
  ],
  "files": [
    {
      "path": "components/ui/gooey-input.tsx",
      "content": "\"use client\";\n\nimport React, {\n  useState,\n  useRef,\n  useId,\n  useMemo,\n  useCallback,\n  type ChangeEvent,\n} from \"react\";\nimport { motion, AnimatePresence } from \"framer-motion\";\nimport { X } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nfunction GooeyFilter({\n  filterId,\n  blur,\n}: {\n  filterId: string;\n  blur: number;\n}) {\n  return (\n    <svg\n      className=\"pointer-events-none absolute h-0 w-0 opacity-0\"\n      aria-hidden=\"true\"\n    >\n      <defs>\n        <filter id={filterId} x=\"-50%\" y=\"-50%\" width=\"200%\" height=\"200%\">\n          <feGaussianBlur in=\"SourceGraphic\" stdDeviation={blur} result=\"blur\" />\n          <feColorMatrix\n            in=\"blur\"\n            mode=\"matrix\"\n            values=\"1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 19 -9\"\n            result=\"goo\"\n          />\n          <feComposite in=\"SourceGraphic\" in2=\"goo\" operator=\"atop\" />\n        </filter>\n      </defs>\n    </svg>\n  );\n}\n\nfunction SearchIcon({ layoutId }: { layoutId: string }) {\n  return (\n    <motion.svg\n      layoutId={layoutId}\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      className=\"block size-4 shrink-0\"\n    >\n      <circle cx=\"11\" cy=\"11\" r=\"8\" />\n      <path d=\"m21 21-4.3-4.3\" />\n    </motion.svg>\n  );\n}\n\nconst transition = {\n  type: \"spring\" as const,\n  stiffness: 440,\n  damping: 28,\n  mass: 0.8,\n};\n\nconst iconBubbleVariants = {\n  collapsed: { scale: 0.6, opacity: 0 },\n  expanded: { scale: 1, opacity: 1 },\n};\n\nexport interface GooeyInputClassNames {\n  root?: string;\n  filterWrap?: string;\n  buttonRow?: string;\n  trigger?: string;\n  input?: string;\n  bubble?: string;\n  bubbleSurface?: string;\n}\n\nexport interface GooeyInputProps {\n  placeholder?: string;\n  className?: string;\n  classNames?: GooeyInputClassNames;\n  /** Collapsed control width in px */\n  collapsedWidth?: number;\n  /** Expanded control width in px */\n  expandedWidth?: number;\n  /** Horizontal offset when expanded (px), aligns detached bubble */\n  expandedOffset?: number;\n  /** Gaussian blur amount for the gooey SVG filter */\n  gooeyBlur?: number;\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  onOpenChange?: (open: boolean) => void;\n  disabled?: boolean;\n}\n\nexport function GooeyInput({\n  placeholder = \"Type to search...\",\n  className,\n  classNames,\n  collapsedWidth = 115,\n  expandedWidth = 200,\n  expandedOffset = 50,\n  gooeyBlur = 5,\n  value: valueProp,\n  defaultValue = \"\",\n  onValueChange,\n  onOpenChange,\n  disabled = false,\n}: GooeyInputProps) {\n  const reactId = useId();\n  const safeId = reactId.replace(/:/g, \"\");\n  const filterId = `gooey-filter-${safeId}`;\n  const iconLayoutId = `gooey-input-icon-${safeId}`;\n\n  const containerRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const [isExpanded, setIsExpanded] = useState(false);\n  const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);\n\n  const isControlled = valueProp !== undefined;\n  const searchText = isControlled ? valueProp : uncontrolledValue;\n\n  const setSearchText = useCallback(\n    (next: string) => {\n      if (!isControlled) {\n        setUncontrolledValue(next);\n      }\n      onValueChange?.(next);\n    },\n    [isControlled, onValueChange],\n  );\n\n  const setExpanded = useCallback(\n    (next: boolean) => {\n      setIsExpanded(next);\n      onOpenChange?.(next);\n    },\n    [onOpenChange],\n  );\n\n  const handleExpand = useCallback(() => {\n    if (disabled) return;\n    setExpanded(true);\n    setTimeout(() => {\n      inputRef.current?.focus();\n    }, 50);\n  }, [disabled, setExpanded]);\n\n  const handleChange = useCallback(\n    (e: ChangeEvent<HTMLInputElement>) => {\n      setSearchText(e.target.value);\n    },\n    [setSearchText],\n  );\n\n  // Safe blur handler: only collapse if focus genuinely moves outside this component\n  const handleContainerBlur = useCallback(\n    (e: React.FocusEvent<HTMLDivElement>) => {\n      const nextTarget = e.relatedTarget as Node | null;\n      if (!containerRef.current?.contains(nextTarget)) {\n        if (!searchText) {\n          setExpanded(false);\n        }\n      }\n    },\n    [searchText, setExpanded],\n  );\n\n  const handleBubbleClick = useCallback(() => {\n    if (disabled) return;\n    if (isExpanded) {\n      if (!searchText) {\n        setExpanded(false);\n      } else {\n        inputRef.current?.focus();\n      }\n    } else {\n      handleExpand();\n    }\n  }, [disabled, isExpanded, searchText, setExpanded, handleExpand]);\n\n  const pillVariants = useMemo(\n    () => ({\n      collapsed: { width: collapsedWidth, x: 0 },\n      expanded: { width: expandedWidth, x: expandedOffset },\n    }),\n    [collapsedWidth, expandedWidth, expandedOffset],\n  );\n\n  const liquidBubbleVariants = useMemo(\n    () => ({\n      collapsed: { scale: 0.6, opacity: 0 },\n      expanded: { scale: 1, opacity: 1 },\n    }),\n    [],\n  );\n\n  const surfaceBgClass = \"bg-foreground shadow-sm ring-1 ring-border/60\";\n\n  return (\n    <div\n      ref={containerRef}\n      onBlur={handleContainerBlur}\n      className={cn(\n        \"relative inline-flex items-center select-none\",\n        className,\n        classNames?.root,\n      )}\n      role=\"search\"\n      aria-expanded={isExpanded}\n    >\n      {/* ── SVG GOOEY METABALL FILTER DEFINITION ── */}\n      <GooeyFilter filterId={filterId} blur={gooeyBlur} />\n\n      {/* ── LAYER 1: LIQUID BACKGROUND CANVAS (Filtered) ── */}\n      <div\n        className={cn(\n          \"pointer-events-none absolute inset-0 flex h-10 items-center\",\n          classNames?.filterWrap,\n        )}\n        style={{ filter: `url(#${filterId})` }}\n        aria-hidden=\"true\"\n      >\n        {/* Detached Bubble Liquid Shape */}\n        <motion.div\n          className={cn(\n            \"absolute left-0 top-0 size-10 rounded-full\",\n            surfaceBgClass,\n            classNames?.bubbleSurface,\n          )}\n          variants={liquidBubbleVariants}\n          initial=\"collapsed\"\n          animate={isExpanded ? \"expanded\" : \"collapsed\"}\n          transition={transition}\n        />\n\n        {/* Expanding Pill Liquid Shape */}\n        <motion.div\n          className={cn(\n            \"h-10 rounded-full\",\n            surfaceBgClass,\n            classNames?.buttonRow,\n          )}\n          variants={pillVariants}\n          initial=\"collapsed\"\n          animate={isExpanded ? \"expanded\" : \"collapsed\"}\n          transition={transition}\n        />\n      </div>\n\n      {/* ── LAYER 2: INTERACTIVE CONTENT (Unfiltered, Crisp Text & Icon) ── */}\n      <div className=\"relative flex h-10 items-center\">\n        {/* Detached Search Bubble Button (Perfect 40px center alignment) */}\n        <motion.button\n          type=\"button\"\n          tabIndex={isExpanded ? 0 : -1}\n          disabled={disabled}\n          onClick={handleBubbleClick}\n          aria-label={isExpanded ? \"Search or toggle input\" : \"Open search\"}\n          className={cn(\n            \"absolute left-0 top-0 m-0 p-0 flex size-10 items-center justify-center rounded-full text-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50\",\n            classNames?.bubble,\n          )}\n          variants={iconBubbleVariants}\n          initial=\"collapsed\"\n          animate={isExpanded ? \"expanded\" : \"collapsed\"}\n          transition={transition}\n        >\n          {isExpanded && <SearchIcon layoutId={iconLayoutId} />}\n        </motion.button>\n\n        {/* Expanding Input Bar */}\n        <motion.div\n          className={cn(\n            \"relative flex h-10 items-center overflow-hidden rounded-full\",\n            classNames?.buttonRow,\n          )}\n          variants={pillVariants}\n          initial=\"collapsed\"\n          animate={isExpanded ? \"expanded\" : \"collapsed\"}\n          transition={transition}\n          onClick={() => {\n            if (!isExpanded) handleExpand();\n          }}\n        >\n          <div\n            className={cn(\n              \"flex h-10 w-full items-center gap-2 rounded-full px-3.5 text-sm font-medium transition-[box-shadow] focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2\",\n              !isExpanded && \"cursor-pointer\",\n              classNames?.trigger,\n            )}\n          >\n            {/* Search Icon inside pill when collapsed */}\n            {!isExpanded && (\n              <span className=\"flex size-4 shrink-0 items-center justify-center text-background\">\n                <SearchIcon layoutId={iconLayoutId} />\n              </span>\n            )}\n\n            {/* Real Search Input */}\n            <input\n              ref={inputRef}\n              type=\"search\"\n              enterKeyHint=\"search\"\n              autoComplete=\"off\"\n              value={searchText}\n              onChange={handleChange}\n              onFocus={() => {\n                if (!isExpanded) setExpanded(true);\n              }}\n              onKeyDown={(e) => {\n                if (e.key === \"Escape\") {\n                  setSearchText(\"\");\n                  setExpanded(false);\n                  inputRef.current?.blur();\n                }\n              }}\n              disabled={disabled || !isExpanded}\n              placeholder={placeholder}\n              className={cn(\n                \"h-full min-w-0 flex-1 bg-transparent text-sm leading-normal text-background outline-none\",\n                \"[&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden\",\n                isExpanded\n                  ? \"cursor-text placeholder:text-background/50\"\n                  : \"cursor-pointer pointer-events-none select-none placeholder:text-background/80\",\n                classNames?.input,\n              )}\n            />\n\n            {/* Clear Input Button (visible when text is entered) */}\n            <AnimatePresence>\n              {isExpanded && searchText && (\n                <motion.button\n                  type=\"button\"\n                  initial={{ scale: 0, opacity: 0 }}\n                  animate={{ scale: 1, opacity: 1 }}\n                  exit={{ scale: 0, opacity: 0 }}\n                  transition={{ duration: 0.15 }}\n                  onClick={(e) => {\n                    e.stopPropagation();\n                    setSearchText(\"\");\n                    inputRef.current?.focus();\n                  }}\n                  className=\"flex size-4 shrink-0 items-center justify-center rounded-full text-background/60 hover:text-background focus:outline-none\"\n                  aria-label=\"Clear search input\"\n                >\n                  <X className=\"size-3\" />\n                </motion.button>\n              )}\n            </AnimatePresence>\n          </div>\n        </motion.div>\n      </div>\n    </div>\n  );\n}\n\nexport default GooeyInput;\n",
      "type": "registry:ui",
      "target": "components/ui/gooey-input.tsx"
    },
    {
      "path": "lib/utils.ts",
      "content": "import { type ClassValue, clsx } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n\nexport async function copyToClipboard(text: string): Promise<boolean> {\n  if (typeof window === \"undefined\") return false;\n\n  try {\n    if (navigator.clipboard && window.isSecureContext) {\n      await navigator.clipboard.writeText(text);\n      return true;\n    }\n\n    const textArea = document.createElement(\"textarea\");\n    textArea.value = text;\n    textArea.style.position = \"fixed\";\n    textArea.style.left = \"-999999px\";\n    textArea.style.top = \"-999999px\";\n    document.body.appendChild(textArea);\n    textArea.focus();\n    textArea.select();\n    const succeeded = document.execCommand(\"copy\");\n    textArea.remove();\n    return succeeded;\n  } catch {\n    return false;\n  }\n}\n",
      "type": "registry:lib",
      "target": "lib/utils.ts"
    }
  ]
}
