{
  "name": "gooey-input",
  "type": "registry:ui",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "components/ui/gooey-input.tsx",
      "content": "\"use client\";\n\nimport {\n  useState,\n  useRef,\n  useEffect,\n  useId,\n  useMemo,\n  useCallback,\n  type ChangeEvent,\n} from \"react\";\nimport { motion } from \"motion/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 className=\"absolute hidden h-0 w-0\" aria-hidden>\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            type=\"matrix\"\n            values=\"1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 20 -10\"\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=\"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  duration: 0.4,\n  type: \"spring\" as const,\n  bounce: 0.25,\n};\n\nconst iconBubbleVariants = {\n  collapsed: { scale: 0, 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  const inputLayoutId = `gooey-input-field-${safeId}`;\n\n  const inputRef = useRef<HTMLInputElement>(null);\n  const prevExpandedRef = useRef(false);\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  useEffect(() => {\n    if (isExpanded) {\n      inputRef.current?.focus();\n    } else if (prevExpandedRef.current) {\n      setSearchText(\"\");\n    }\n    prevExpandedRef.current = isExpanded;\n  }, [isExpanded, setSearchText]);\n\n  const buttonVariants = useMemo(\n    () => ({\n      collapsed: { width: collapsedWidth, marginLeft: 0 },\n      expanded: { width: expandedWidth, marginLeft: expandedOffset },\n    }),\n    [collapsedWidth, expandedWidth, expandedOffset],\n  );\n\n  const handleExpand = useCallback(() => {\n    if (!disabled) setExpanded(true);\n  }, [disabled, setExpanded]);\n\n  const handleChange = useCallback(\n    (e: ChangeEvent<HTMLInputElement>) => {\n      setSearchText(e.target.value);\n    },\n    [setSearchText],\n  );\n\n  const handleBlur = useCallback(() => {\n    if (!searchText) setExpanded(false);\n  }, [searchText, setExpanded]);\n\n  const surfaceClass =\n    \"bg-foreground text-background shadow-sm ring-1 ring-border/60\";\n\n  return (\n    <div\n      className={cn(\n        \"relative flex items-center justify-center\",\n        className,\n        classNames?.root,\n      )}\n    >\n      <GooeyFilter filterId={filterId} blur={gooeyBlur} />\n\n      <div\n        className={cn(\n          \"relative flex h-10 items-center justify-center\",\n          classNames?.filterWrap,\n        )}\n        style={{ filter: `url(#${filterId})` }}\n      >\n        <motion.div\n          className={cn(\"flex h-10 items-center justify-center\", classNames?.buttonRow)}\n          variants={buttonVariants}\n          initial=\"collapsed\"\n          animate={isExpanded ? \"expanded\" : \"collapsed\"}\n          transition={transition}\n        >\n          <button\n            type=\"button\"\n            disabled={disabled}\n            onClick={handleExpand}\n            className={cn(\n              \"flex h-10 w-full cursor-pointer items-center justify-center gap-2 rounded-full px-4 text-sm font-medium outline-none transition-[color,box-shadow] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50\",\n              surfaceClass,\n              classNames?.trigger,\n            )}\n          >\n            {!isExpanded ? (\n              <SearchIcon layoutId={iconLayoutId} />\n            ) : null}\n            <motion.input\n              layoutId={inputLayoutId}\n              ref={inputRef}\n              type=\"search\"\n              enterKeyHint=\"search\"\n              autoComplete=\"off\"\n              value={searchText}\n              onChange={handleChange}\n              onBlur={handleBlur}\n              disabled={disabled || !isExpanded}\n              placeholder={placeholder}\n              className={cn(\n                \"h-full min-w-0 flex-1 bg-transparent text-sm text-background outline-none\",\n                isExpanded\n                  ? \"placeholder:text-background/50 dark:placeholder:text-background/45\"\n                  : \"pointer-events-none placeholder:text-background/80 dark:placeholder:text-background/70\",\n                classNames?.input,\n              )}\n            />\n          </button>\n        </motion.div>\n\n        <motion.div\n          className={cn(\n            \"absolute top-1/2 left-0 flex size-10 -translate-y-1/2 items-center justify-center\",\n            classNames?.bubble,\n          )}\n          variants={iconBubbleVariants}\n          initial=\"collapsed\"\n          animate={isExpanded ? \"expanded\" : \"collapsed\"}\n          transition={transition}\n        >\n          <div\n            className={cn(\n              \"flex size-10 items-center justify-center rounded-full\",\n              surfaceClass,\n              classNames?.bubbleSurface,\n            )}\n          >\n            <SearchIcon layoutId={iconLayoutId} />\n          </div>\n        </motion.div>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/gooey-input.tsx"
    }
  ],
  "author": "Manu Arora <hi@manuarora.in>",
  "title": "Gooey Input"
}