{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "gooey-input",
  "title": "Gooey input",
  "description": "A pill text field whose submit button is a droplet of the same liquid: on focus it pulls away from the capsule through a gooey SVG bridge and lands beside it with its icon.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/items/gooey-input/gooey-input.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotionConfig,\n  type Transition,\n} from \"motion/react\";\nimport {\n  ArrowRight,\n  Check,\n  Loader2,\n  Plus,\n  Send,\n  Sparkles,\n  X,\n} from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * A pill-shaped text field whose submit button is a droplet of the same\n * liquid. At rest the droplet sits inside the capsule's right end, invisible;\n * on focus it pulls away, stretching a gooey bridge that snaps, and lands\n * beside the field wearing its icon.\n *\n * The liquid is an SVG silhouette layer under a crisp DOM layer: two rounded\n * rects run through a blur-then-alpha-contrast filter (the classic \"goo\"\n * metaball trick), and the card's shadow is painted on the merged silhouette\n * inside the same filter, so the bridge casts one shadow, not two. The DOM\n * field and button move in lockstep with their rects on one motion\n * transition.\n */\n\nexport type GooeyInputIcon = \"arrow\" | \"send\" | \"sparkles\" | \"check\" | \"x\" | \"plus\";\n\nexport interface GooeyInputProps {\n  /** Input type. `email` switches on the built-in email check. Default `text`. */\n  type?: \"text\" | \"email\" | \"url\" | \"search\" | \"tel\";\n  /** Controlled value. Pair with `onValueChange`. */\n  value?: string;\n  /** Starting value when uncontrolled. A new value resets the field. */\n  defaultValue?: string;\n  /** Every keystroke, in both modes. */\n  onValueChange?: (value: string) => void;\n  /**\n   * Form field name. The value is mirrored into a hidden input under this\n   * name, so it still posts with the enclosing form while the field is\n   * disabled for a pending submit.\n   */\n  name?: string;\n  /** Placeholder, when there is no `label`. */\n  placeholder?: string;\n  /**\n   * Floating label: rests inside the capsule as the placeholder while empty,\n   * rides up onto the capsule's top edge on focus or value. Replaces\n   * `placeholder` and becomes the field's accessible name.\n   */\n  label?: string;\n  /** Accessible name when there is no visible `label`. */\n  ariaLabel?: string;\n  /**\n   * Button-face text. While the field is empty and blurred the capsule wears\n   * this like a button title; focusing it flips it into a plain field.\n   */\n  restLabel?: string;\n\n  /**\n   * Called on Enter or a droplet tap once the value passes validation. Return a\n   * promise and the droplet spins while it runs, then shows a tick (or the\n   * `failureMessage` if it rejects). Without it, the enclosing form is\n   * submitted instead.\n   */\n  onSubmit?: (value: string) => void | Promise<void>;\n  /**\n   * Synchronous check before submit. Return a message to show, or null.\n   * Defaults to an email check for `type=\"email\"`, otherwise none.\n   */\n  validate?: (value: string) => string | null;\n  /** Shown when submitting an empty field. */\n  requiredMessage?: string;\n  /** Shown when an `onSubmit` promise rejects. */\n  failureMessage?: string;\n  /**\n   * Live check while typing: debounced, the droplet spins while it runs and\n   * shows a tick when the value verifies. A tick is a verdict, not a button —\n   * Enter still submits.\n   */\n  validateAsync?: (value: string) => Promise<string | null>;\n  /** Debounce before a live check fires, ms. Default 600. */\n  validationDelayMs?: number;\n  /** Empty an uncontrolled field after a successful promise submit. */\n  clearOnSuccess?: boolean;\n  /** Placeholder shown for a moment after a cleared successful submit. */\n  successMessage?: string;\n\n  /**\n   * External busy flag, for callers that own the async work (a form action in\n   * flight). The droplet stays out and spins, field and droplet disable.\n   */\n  pending?: boolean;\n  disabled?: boolean;\n  /** Icon the droplet wears. Default `arrow`. */\n  buttonIcon?: GooeyInputIcon;\n  /** Busy face: a spinner, or a glowing rolling square in the lead colour. */\n  loadingIcon?: \"spinner\" | \"square\";\n  /** Accessible name of the droplet. Default \"Submit\". */\n  submitLabel?: string;\n  /**\n   * Replaces submit on a droplet tap (e.g. an `x` droplet that dismisses the\n   * field). Enter still submits.\n   */\n  onDropletClick?: () => void;\n  /** Accessible name of the droplet when `onDropletClick` is set. */\n  dropletLabel?: string;\n  /** Escape while focused: abandon the field. */\n  onEscape?: () => void;\n\n  /** Height preset. Default `md`. */\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** Capsule width in px. Defaults by size: 190 / 240 / 270. */\n  fieldWidth?: number;\n  /**\n   * Where the capsule rests. `center` floats it in a roomy stage; `start`\n   * puts its left edge on the row's edge, shrinking the capsule (down to\n   * 150px) when the row is too narrow for it plus the droplet's travel.\n   */\n  align?: \"center\" | \"start\";\n  /** Fill the parent's width (minus the droplet's travel). Implies `start`. */\n  fluid?: boolean;\n\n  /** Goo blur sigma in px — how far apart the pieces start bridging. Default 6. */\n  gooBlur?: number;\n  /** Alpha-contrast slope — how sharp the liquid edge is. Default 14. */\n  gooContrast?: number;\n  /** Distance in px the droplet travels away on focus. Default 54. */\n  gap?: number;\n  /** `calm` (640ms, no overshoot) suits forms; `bouncy` (380ms) overshoots. */\n  feel?: \"calm\" | \"bouncy\";\n\n  maxLength?: number;\n  autoComplete?: string;\n  autoFocus?: boolean;\n  inputRef?: React.Ref<HTMLInputElement>;\n  className?: string;\n}\n\n/** Narrowest a left-packed capsule shrinks to on a tight row. */\nconst MIN_FIELD_W = 150;\n\n/**\n * Slack on each side of a fluid stage so the goo has somewhere to paint;\n * pulled back off with a negative margin — it is bleed, not padding.\n */\nconst FLUID_BLEED = 14;\n\n/** Room past the droplet's landing spot in a left-packed stage. */\nconst TAIL = 16;\n\nconst FEELS = {\n  calm: { duration: 0.64, ease: [0.4, 0, 0.2, 1] },\n  bouncy: { duration: 0.38, ease: [0.34, 1.56, 0.64, 1] },\n} as const satisfies Record<string, { duration: number; ease: [number, number, number, number] }>;\n\nconst SIZES = {\n  sm: { fieldW: 190, fieldH: 42, btn: 38, icon: 15, text: \"text-xs\", stageH: 74 },\n  md: { fieldW: 240, fieldH: 48, btn: 44, icon: 18, text: \"text-sm\", stageH: 84 },\n  lg: { fieldW: 270, fieldH: 52, btn: 48, icon: 20, text: \"text-base\", stageH: 92 },\n} as const;\n\n/**\n * The card's shadow, as SVG filter passes on the merged silhouette: a 1px\n * ring, a close contact shadow and a soft long one, all mixed from `--ink`\n * so they follow the theme.\n */\nconst SHADOWS = [\n  { dy: 0, blur: 0, spread: 1, color: \"color-mix(in oklab, var(--ink) 8%, transparent)\" },\n  { dy: 2, blur: 6, spread: -1, color: \"color-mix(in oklab, var(--ink) 6%, transparent)\" },\n  { dy: 12, blur: 28, spread: -4, color: \"color-mix(in oklab, var(--ink) 10%, transparent)\" },\n] as const;\n\n/** Snaps the soft goo alpha to a hard mask, for the spread passes. */\nconst BINARIZE = \"1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 60 -29.5\";\n\nconst EMAIL = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\nconst emailCheck = (v: string) => (EMAIL.test(v.trim()) ? null : \"Please enter a valid email\");\n\nfunction GooFilter({\n  id,\n  blur,\n  contrast,\n  width,\n  height,\n}: {\n  id: string;\n  blur: number;\n  contrast: number;\n  width: number;\n  height: number;\n}) {\n  const intercept = Math.round((0.5 - contrast * (5 / 12)) * 100) / 100;\n  const extent = Math.max(...SHADOWS.map((s) => s.dy + s.blur * 1.5 + Math.max(0, s.spread)));\n  const pad = Math.ceil(blur * 3 + extent + 24);\n  return (\n    <filter\n      id={id}\n      filterUnits=\"userSpaceOnUse\"\n      x={-pad}\n      y={-pad}\n      width={width + pad * 2}\n      height={height + pad * 2}\n      colorInterpolationFilters=\"sRGB\"\n    >\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 ${contrast} ${intercept}`}\n        result=\"goo\"\n      />\n      <feComposite in=\"SourceGraphic\" in2=\"goo\" operator=\"atop\" result=\"shape\" />\n      <feColorMatrix in=\"shape\" type=\"matrix\" values={BINARIZE} result=\"bin\" />\n      {SHADOWS.map((s, i) => (\n        <React.Fragment key={i}>\n          <feMorphology\n            in=\"bin\"\n            operator={s.spread > 0 ? \"dilate\" : \"erode\"}\n            radius={Math.abs(s.spread)}\n            result={`s${i}-sp`}\n          />\n          <feGaussianBlur in={`s${i}-sp`} stdDeviation={s.blur / 2} result={`s${i}-b`} />\n          <feOffset in={`s${i}-b`} dx={0} dy={s.dy} result={`s${i}-o`} />\n          <feFlood style={{ floodColor: s.color }} result={`s${i}-c`} />\n          <feComposite in={`s${i}-c`} in2={`s${i}-o`} operator=\"in\" result={`s${i}`} />\n        </React.Fragment>\n      ))}\n      <feMerge>\n        {SHADOWS.map((_, i) => SHADOWS.length - 1 - i).map((i) => (\n          <feMergeNode key={i} in={`s${i}`} />\n        ))}\n        <feMergeNode in=\"shape\" />\n      </feMerge>\n    </filter>\n  );\n}\n\nexport function GooeyInput({\n  type = \"text\",\n  value,\n  defaultValue = \"\",\n  onValueChange,\n  name,\n  placeholder = \"Type here…\",\n  label,\n  ariaLabel,\n  restLabel,\n  onSubmit,\n  validate,\n  requiredMessage = \"Please enter a value\",\n  failureMessage = \"That didn't go through. Please try again.\",\n  validateAsync,\n  validationDelayMs = 600,\n  clearOnSuccess = false,\n  successMessage,\n  pending = false,\n  disabled = false,\n  buttonIcon = \"arrow\",\n  loadingIcon = \"spinner\",\n  submitLabel = \"Submit\",\n  onDropletClick,\n  dropletLabel = \"Close\",\n  onEscape,\n  size = \"md\",\n  fieldWidth,\n  align = \"center\",\n  fluid = false,\n  gooBlur = 6,\n  gooContrast = 14,\n  gap = 54,\n  feel = \"calm\",\n  maxLength,\n  autoComplete = \"off\",\n  autoFocus = false,\n  inputRef: externalInputRef,\n  className,\n}: GooeyInputProps) {\n  const reactId = React.useId();\n  const uid = reactId.replace(/[^a-zA-Z0-9_-]/g, \"\");\n  const filterId = `gooey-input-${uid}`;\n  const messageId = `gooey-input-msg-${uid}`;\n\n  const reduced = useReducedMotionConfig() ?? false;\n  const motionSpec = FEELS[feel];\n  const travel: Transition = reduced\n    ? { duration: 0 }\n    : { duration: motionSpec.duration, ease: motionSpec.ease };\n  const durationMs = motionSpec.duration * 1000;\n\n  // Value: controlled when `value` is passed, otherwise owned here and reset\n  // whenever a new `defaultValue` arrives.\n  const [ownValue, setOwnValue] = React.useState(defaultValue);\n  const [prevDefault, setPrevDefault] = React.useState(defaultValue);\n  if (defaultValue !== prevDefault) {\n    setPrevDefault(defaultValue);\n    setOwnValue(defaultValue);\n  }\n  const controlled = value !== undefined;\n  const text = controlled ? value : ownValue;\n\n  const [isFocused, setIsFocused] = React.useState(false);\n  const [status, setStatus] = React.useState<\"idle\" | \"loading\" | \"success\">(\"idle\");\n  const [message, setMessage] = React.useState<string | null>(null);\n  const [isPulsing, setIsPulsing] = React.useState(false);\n\n  const fieldRef = React.useRef<HTMLInputElement>(null);\n  React.useImperativeHandle(externalInputRef, () => fieldRef.current as HTMLInputElement, []);\n\n  const busy = pending || status === \"loading\";\n  const dropletOut = isFocused || busy;\n  const isStart = fluid || align === \"start\";\n\n  // Every timer this field starts, so unmount can cancel them.\n  const timers = React.useRef(new Set<ReturnType<typeof setTimeout>>());\n  const later = React.useCallback((fn: () => void, ms: number) => {\n    const t = setTimeout(() => {\n      timers.current.delete(t);\n      fn();\n    }, ms);\n    timers.current.add(t);\n  }, []);\n  React.useEffect(() => {\n    const scheduled = timers.current;\n    return () => scheduled.forEach(clearTimeout);\n  }, []);\n\n  const flash = (msg: string) => {\n    setMessage(msg);\n    later(() => setMessage(null), 2500);\n  };\n\n  // Live validation: one check per pause in typing; a stale answer that lands\n  // after newer typing is dropped rather than applied.\n  const live = validateAsync !== undefined;\n  const [checking, setChecking] = React.useState(false);\n  const [verdict, setVerdict] = React.useState<boolean | null>(null);\n  const checkId = React.useRef(0);\n  React.useEffect(() => {\n    if (!validateAsync) return;\n    const id = ++checkId.current;\n    if (!text.trim()) {\n      const reset = setTimeout(() => {\n        setVerdict(null);\n        setChecking(false);\n      }, 0);\n      return () => clearTimeout(reset);\n    }\n    const timer = setTimeout(async () => {\n      setChecking(true);\n      try {\n        const problem = await validateAsync(text);\n        if (checkId.current === id) setVerdict(problem === null);\n      } catch {\n        // An unreachable validator is \"not confirmed\", never \"confirmed\".\n        if (checkId.current === id) setVerdict(false);\n      } finally {\n        if (checkId.current === id) setChecking(false);\n      }\n    }, validationDelayMs);\n    return () => clearTimeout(timer);\n  }, [text, validateAsync, validationDelayMs]);\n\n  // A brief cross-blur on the droplet's icon as it arrives or leaves.\n  React.useEffect(() => {\n    if (reduced) return;\n    let timeout: ReturnType<typeof setTimeout> | undefined;\n    const raf = requestAnimationFrame(() => {\n      setIsPulsing(true);\n      timeout = setTimeout(() => setIsPulsing(false), durationMs + 80);\n    });\n    return () => {\n      cancelAnimationFrame(raf);\n      if (timeout) clearTimeout(timeout);\n    };\n  }, [isFocused, durationMs, reduced]);\n\n  // The stage is absolutely laid out in px, so a left-packed or fluid stage\n  // has to be told how much room its parent offers. Null until measured, so\n  // the server render and first paint use the preset.\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const [roomW, setRoomW] = React.useState<number | null>(null);\n  React.useEffect(() => {\n    if (!isStart) return;\n    const parent = rootRef.current?.parentElement;\n    if (!parent) return;\n    const observer = new ResizeObserver(([entry]) => {\n      setRoomW(Math.floor(entry.contentRect.width));\n    });\n    observer.observe(parent);\n    return () => observer.disconnect();\n  }, [isStart]);\n\n  const preset = SIZES[size];\n  const isFluid = fluid && roomW !== null;\n  const preferredW = fieldWidth ?? preset.fieldW;\n  const fieldW = isFluid\n    ? Math.max(MIN_FIELD_W, roomW - gap)\n    : isStart && roomW !== null\n      ? Math.max(MIN_FIELD_W, Math.min(preferredW, roomW - gap - TAIL))\n      : preferredW;\n  const { fieldH, btn: btnSize, icon: iconSize } = preset;\n  const stageH = isStart ? fieldH + 10 : preset.stageH;\n  const stageW = isFluid\n    ? roomW + FLUID_BLEED * 2\n    : isStart\n      ? Math.ceil(fieldW + gap + TAIL)\n      : Math.max(340, fieldW + btnSize + gap + 40);\n  const fieldLeft = isFluid ? FLUID_BLEED : isStart ? 0 : stageW / 2 - fieldW / 2 - 10;\n  const fieldTop = (stageH - fieldH) / 2;\n  const btnLeft = fieldLeft + fieldW - btnSize;\n  const btnTop = (stageH - btnSize) / 2;\n  // The capsule is furniture and stays put; the droplet travels the whole gap.\n  const btnShift = dropletOut ? gap : 0;\n\n  const isFloated = isFocused || text.length > 0;\n  const resting = restLabel !== undefined && !isFocused && !text;\n  const hasText = text.trim() !== \"\";\n  const liveChecking = live && hasText && (checking || verdict === null);\n  const liveTick = live && hasText && !checking && verdict === true;\n\n  const setText = (next: string) => {\n    if (!controlled) setOwnValue(next);\n    onValueChange?.(next);\n    if (message) setMessage(null);\n  };\n\n  const submit = async () => {\n    if (disabled || busy) return;\n    const check = validate ?? (type === \"email\" ? emailCheck : null);\n    const problem = !text.trim() ? requiredMessage : check?.(text) ?? null;\n    if (problem) {\n      flash(problem);\n      return;\n    }\n    if (!onSubmit) {\n      fieldRef.current?.closest(\"form\")?.requestSubmit();\n      return;\n    }\n    const result = onSubmit(text);\n    if (!(result instanceof Promise)) return;\n    setStatus(\"loading\");\n    setMessage(null);\n    try {\n      await result;\n      setStatus(\"success\");\n      if (clearOnSuccess && !controlled) setOwnValue(\"\");\n      fieldRef.current?.blur();\n      later(() => setStatus(\"idle\"), 3500);\n    } catch {\n      setStatus(\"idle\");\n      flash(failureMessage);\n    }\n  };\n\n  const renderIcon = () => {\n    if (busy || liveChecking) {\n      if (busy && loadingIcon === \"square\") {\n        return (\n          <span\n            aria-hidden=\"true\"\n            className=\"size-4.5 animate-spin rounded-sm bg-lead shadow-[0_0_10px_var(--lead)]\"\n            style={{ animationDuration: \"2.5s\" }}\n          />\n        );\n      }\n      return <Loader2 aria-hidden=\"true\" className=\"animate-spin\" size={iconSize} />;\n    }\n    if (status === \"success\" || liveTick) {\n      return <Check aria-hidden=\"true\" className=\"text-go\" size={iconSize} />;\n    }\n    const Icon = {\n      arrow: ArrowRight,\n      send: Send,\n      sparkles: Sparkles,\n      check: Check,\n      x: X,\n      plus: Plus,\n    }[buttonIcon];\n    return (\n      <Icon aria-hidden=\"true\" size={iconSize} className={buttonIcon === \"send\" ? \"ml-0.5\" : undefined} />\n    );\n  };\n\n  const dropletName = busy\n    ? `${onDropletClick ? dropletLabel : submitLabel} — working…`\n    : liveTick\n      ? \"Verified\"\n      : onDropletClick\n        ? dropletLabel\n        : submitLabel;\n\n  const shownPlaceholder =\n    label || resting\n      ? undefined\n      : status === \"success\" && clearOnSuccess && successMessage\n        ? successMessage\n        : placeholder;\n\n  return (\n    <div\n      ref={rootRef}\n      className={cn(\n        \"relative flex flex-col select-none\",\n        fluid ? \"w-full items-stretch\" : \"inline-flex\",\n        !fluid && (isStart ? \"items-start\" : \"items-center\"),\n        className,\n      )}\n      style={isFluid ? { marginLeft: -FLUID_BLEED, marginRight: -FLUID_BLEED } : undefined}\n    >\n      {name && <input type=\"hidden\" name={name} value={text} />}\n\n      <div className=\"relative isolate\" style={{ width: stageW, height: stageH }}>\n        {/* Silhouette: the liquid itself, under the crisp content. */}\n        <svg\n          aria-hidden=\"true\"\n          focusable=\"false\"\n          className=\"pointer-events-none absolute inset-0 -z-10 size-full overflow-visible\"\n          style={{ willChange: \"filter, transform\" }}\n        >\n          <defs>\n            <GooFilter\n              id={filterId}\n              blur={gooBlur}\n              contrast={gooContrast}\n              width={stageW}\n              height={stageH}\n            />\n          </defs>\n          <g filter={`url(#${filterId})`} className=\"fill-card\">\n            <rect x={fieldLeft} y={fieldTop} width={fieldW} height={fieldH} rx={fieldH / 2} />\n            <motion.g initial={false} animate={{ x: btnShift }} transition={travel}>\n              <rect x={btnLeft} y={btnTop} width={btnSize} height={btnSize} rx={btnSize / 2} />\n            </motion.g>\n          </g>\n        </svg>\n\n        {/* 1. The capsule. */}\n        <div\n          className=\"absolute flex items-center rounded-full\"\n          style={{ left: fieldLeft, top: fieldTop, width: fieldW, height: fieldH }}\n        >\n          {label && (\n            <span\n              aria-hidden=\"true\"\n              className={cn(\n                \"pointer-events-none absolute z-10 origin-left font-body transition-all duration-200 ease-out\",\n                isFloated\n                  ? \"top-0 left-4 -translate-y-1/2 rounded-full bg-card px-1.5 text-[10px] font-bold tracking-[0.12em] text-faint uppercase\"\n                  : cn(\"top-1/2 left-5 -translate-y-1/2 font-medium text-dim\", preset.text),\n              )}\n            >\n              {label}\n            </span>\n          )}\n\n          <input\n            ref={fieldRef}\n            type={type}\n            value={text}\n            title={text || undefined}\n            autoComplete={autoComplete}\n            autoCorrect=\"off\"\n            autoCapitalize=\"off\"\n            spellCheck={false}\n            autoFocus={autoFocus}\n            disabled={disabled || busy}\n            maxLength={maxLength}\n            placeholder={shownPlaceholder}\n            aria-label={label ?? ariaLabel ?? restLabel ?? placeholder}\n            aria-invalid={message ? true : undefined}\n            aria-describedby={message ? messageId : undefined}\n            onChange={(e) => setText(e.target.value)}\n            onFocus={() => setIsFocused(true)}\n            onBlur={() => setIsFocused(false)}\n            onKeyDown={(e) => {\n              if (e.key === \"Enter\") {\n                e.preventDefault();\n                void submit();\n              }\n              if (e.key === \"Escape\" && onEscape) {\n                e.preventDefault();\n                onEscape();\n              }\n            }}\n            className={cn(\n              \"size-full rounded-full border-0 bg-transparent px-5 font-body font-medium text-ink outline-none transition-colors duration-200 placeholder:text-faint disabled:cursor-not-allowed\",\n              preset.text,\n            )}\n          />\n\n          {restLabel !== undefined && (\n            <AnimatePresence initial={false}>\n              {resting && (\n                <motion.span\n                  key=\"rest-label\"\n                  aria-hidden=\"true\"\n                  className=\"pointer-events-none absolute inset-y-0 right-5 left-5 flex items-center justify-center overflow-hidden\"\n                  initial={{ opacity: 0, y: 8, scale: 0.97 }}\n                  animate={{ opacity: 1, y: 0, scale: 1 }}\n                  exit={{ opacity: 0, y: -10, scale: 1.03 }}\n                  transition={reduced ? { duration: 0 } : { duration: 0.32, ease: \"easeOut\" }}\n                >\n                  <span className=\"truncate font-body text-sm font-semibold text-ink\">{restLabel}</span>\n                </motion.span>\n              )}\n            </AnimatePresence>\n          )}\n        </div>\n\n        {/* 2. The droplet. */}\n        <motion.div\n          className=\"absolute\"\n          style={{ left: btnLeft, top: btnTop }}\n          initial={false}\n          animate={{ x: btnShift }}\n          transition={travel}\n        >\n          <button\n            type=\"button\"\n            disabled={disabled || busy}\n            tabIndex={isFocused ? 0 : -1}\n            aria-label={dropletName}\n            onPointerDown={(e) => {\n              // Keep the field focused through the tap — but only once the\n              // droplet is out; while hidden, taps must reach the field.\n              if (isFocused) e.preventDefault();\n            }}\n            onClick={() => {\n              if (busy) return;\n              // A hidden droplet is a funnel, not a control: a tap on its zone\n              // focuses the field.\n              if (!isFocused) {\n                fieldRef.current?.focus();\n                return;\n              }\n              if (liveChecking || liveTick) return;\n              if (onDropletClick) {\n                fieldRef.current?.blur();\n                onDropletClick();\n              } else void submit();\n            }}\n            className={cn(\n              \"relative flex items-center justify-center rounded-full border-0 p-0 text-ink transition-all duration-200 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none active:scale-95 disabled:cursor-not-allowed\",\n              dropletOut ? \"cursor-pointer hover:bg-paper\" : \"cursor-text\",\n            )}\n            style={{ width: btnSize, height: btnSize }}\n          >\n            {/* `filter-none`, not `blur-0`: `blur(0px)` is still a filter and\n                would defeat a `backdrop-filter` this field sits inside. */}\n            <span\n              className={cn(\n                \"flex items-center justify-center transition-all duration-200\",\n                dropletOut ? \"scale-100 opacity-100\" : \"pointer-events-none scale-75 opacity-0\",\n                isPulsing ? \"blur-[0.5px]\" : \"filter-none\",\n              )}\n              style={{\n                transitionDelay: dropletOut ? `${Math.round(durationMs * 0.15)}ms` : \"0ms\",\n              }}\n            >\n              {renderIcon()}\n            </span>\n          </button>\n        </motion.div>\n      </div>\n\n      <div\n        id={messageId}\n        role=\"status\"\n        aria-live=\"polite\"\n        className={cn(\n          \"absolute -bottom-6 font-body text-xs font-semibold whitespace-nowrap text-stop\",\n          isFluid ? \"left-[calc(14px+1rem)]\" : isStart ? \"left-4\" : \"left-1/2 -translate-x-1/2\",\n        )}\n      >\n        {message && (\n          <span className=\"block animate-in duration-200 fade-in slide-in-from-top-1\">{message}</span>\n        )}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "kind": "primitive",
    "whenToUse": "Reach for it for a single-value action field that deserves a moment of delight: a sign-up email, a URL to import, an inline rename. Not for multi-field forms, where a plain input and one submit button read better, and not inside overflow-hidden containers that would clip the droplet's travel.",
    "origin": {
      "project": "180Connect",
      "path": "src/components/ui/gooey-text-input.tsx (+ src/components/ui/gooey-email-input.tsx)"
    },
    "credits": [
      {
        "name": "liquid-gooey by Jakub Antalik (goo filter, silhouette/content layering, shadow-on-merged-silhouette passes)",
        "url": "https://gooey.jakubantalik.com",
        "license": "MIT"
      }
    ]
  },
  "type": "registry:ui"
}
