{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["gsap"],"description":"A GooeyPopover component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport gsap from \"gsap\";\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport { useClickOutside } from \"./use-click-outside\";\n\nconst MEASURE_DELAY_SHORT = 100;\nconst MEASURE_DELAY_LONG = 500;\nconst DEFAULT_TRIGGER_SIZE = 44;\nconst DEFAULT_CONTENT_WIDTH = 240;\nconst DEFAULT_SIDE_OFFSET = 24;\nconst DEFAULT_SPEED = 0.25;\nconst GOO_STD_DEVIATION = 10;\nconst GOO_MATRIX_ALPHA_MULTIPLIER = 24;\nconst GOO_MATRIX_ALPHA_OFFSET = -10;\nconst CONTENT_BORDER_RADIUS = 18;\n\nexport type GooeyPopoverProps = {\n  children: React.ReactNode;\n  trigger?: React.ReactNode;\n  triggerSize?: number;\n  isOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  side?: \"top\" | \"bottom\";\n  sideOffset?: number;\n  contentWidth?: number;\n  speed?: number;\n  bgClassName?: string;\n  contentClassName?: string;\n  className?: string;\n};\n\nexport default function GooeyPopover({\n  children,\n  trigger,\n  triggerSize = DEFAULT_TRIGGER_SIZE,\n  isOpen: controlledIsOpen,\n  onOpenChange,\n  side = \"top\",\n  sideOffset = DEFAULT_SIDE_OFFSET,\n  contentWidth = DEFAULT_CONTENT_WIDTH,\n  speed = DEFAULT_SPEED,\n  bgClassName = \"bg-neutral-900\",\n  contentClassName,\n  className,\n}: GooeyPopoverProps) {\n  const filterId = useId();\n  const isControlled = controlledIsOpen !== undefined;\n  const [internalIsOpen, setInternalIsOpen] = useState(false);\n  const isOpen = isControlled ? controlledIsOpen : internalIsOpen;\n  const [isVisible, setIsVisible] = useState(false);\n\n  const containerRef = useRef<HTMLDivElement>(null);\n  const measureRef = useRef<HTMLDivElement>(null);\n  const filteredContentRef = useRef<HTMLDivElement>(null);\n  const unfilteredContentRef = useRef<HTMLDivElement>(null);\n  const innerContentRef = useRef<HTMLDivElement>(null);\n  const timelineRef = useRef<gsap.core.Timeline | null>(null);\n  const [contentHeight, setContentHeight] = useState(0);\n  const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);\n\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    setPrefersReducedMotion(mq.matches);\n    const handler = (e: MediaQueryListEvent) => {\n      setPrefersReducedMotion(e.matches);\n    };\n    mq.addEventListener(\"change\", handler);\n    return () => mq.removeEventListener(\"change\", handler);\n  }, []);\n\n  const setIsOpen = useCallback(\n    (open: boolean) => {\n      if (!isControlled) {\n        setInternalIsOpen(open);\n      }\n      onOpenChange?.(open);\n    },\n    [isControlled, onOpenChange]\n  );\n\n  const handleClose = useCallback(() => {\n    if (isOpen) {\n      setIsOpen(false);\n    }\n  }, [isOpen, setIsOpen]);\n\n  useClickOutside(containerRef, handleClose);\n\n  // Escape key to close\n  useEffect(() => {\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\" && isOpen) {\n        setIsOpen(false);\n      }\n    };\n    window.addEventListener(\"keydown\", handleKeyDown);\n    return () => window.removeEventListener(\"keydown\", handleKeyDown);\n  }, [isOpen, setIsOpen]);\n\n  // Measure content height\n  useEffect(() => {\n    const measureHeight = () => {\n      if (measureRef.current) {\n        const height = measureRef.current.scrollHeight;\n        if (height > 0) {\n          setContentHeight(height);\n        }\n      }\n    };\n\n    const timeoutId = setTimeout(measureHeight, MEASURE_DELAY_SHORT);\n    const timeoutId2 = setTimeout(measureHeight, MEASURE_DELAY_LONG);\n    return () => {\n      clearTimeout(timeoutId);\n      clearTimeout(timeoutId2);\n    };\n  }, [children]);\n\n  const triggerRadius = triggerSize / 2;\n  // Position content so its edge clears the trigger with sideOffset gap\n  const translateY =\n    side === \"top\" ? -(contentHeight + sideOffset) : triggerSize + sideOffset;\n  const contentLeft = triggerRadius - contentWidth / 2;\n\n  // GSAP animations\n  useEffect(() => {\n    if (contentHeight === 0) {\n      return;\n    }\n\n    // Kill any running timeline\n    if (timelineRef.current) {\n      timelineRef.current.kill();\n    }\n\n    const filteredTarget = filteredContentRef.current;\n    const unfilteredTarget = unfilteredContentRef.current;\n    const innerTarget = innerContentRef.current;\n\n    if (!(unfilteredTarget && innerTarget)) {\n      return;\n    }\n\n    if (prefersReducedMotion) {\n      if (isOpen) {\n        setIsVisible(true);\n        gsap.set(unfilteredTarget, {\n          borderRadius: CONTENT_BORDER_RADIUS,\n          height: contentHeight,\n          opacity: 1,\n          width: contentWidth,\n          x: contentLeft,\n          y: translateY,\n        });\n        gsap.set(innerTarget, { opacity: 1, y: 0 });\n      } else {\n        gsap.set(unfilteredTarget, {\n          borderRadius: triggerRadius,\n          height: triggerSize,\n          opacity: 0,\n          width: triggerSize,\n          x: 0,\n          y: 0,\n        });\n        gsap.set(innerTarget, { opacity: 0, y: 0 });\n        setIsVisible(false);\n      }\n      return;\n    }\n\n    if (isOpen) {\n      setIsVisible(true);\n\n      // Start both content shapes as circles at trigger position\n      const startProps = {\n        borderRadius: triggerRadius,\n        height: triggerSize,\n        opacity: 1,\n        width: triggerSize,\n        x: 0,\n        y: 0,\n      };\n      if (filteredTarget) {\n        gsap.set(filteredTarget, startProps);\n      }\n      gsap.set(unfilteredTarget, startProps);\n      gsap.set(innerTarget, { opacity: 0, y: 16 });\n\n      const tl = gsap.timeline();\n\n      // Filtered content: morph to rectangle with borderRadius=0\n      // The goo filter softens the edges naturally\n      if (filteredTarget) {\n        tl.to(\n          filteredTarget,\n          {\n            borderRadius: 0,\n            duration: speed,\n            ease: \"power1.in\",\n            height: contentHeight,\n            width: contentWidth,\n            x: contentLeft,\n            y: translateY,\n          },\n          0\n        );\n      }\n\n      // Unfiltered content: morph to rectangle with rounded corners\n      tl.to(\n        unfilteredTarget,\n        {\n          borderRadius: CONTENT_BORDER_RADIUS,\n          duration: speed,\n          ease: \"power1.in\",\n          height: contentHeight,\n          width: contentWidth,\n          x: contentLeft,\n          y: translateY,\n        },\n        0\n      );\n\n      // Content text fade in (overlapping with shape morph)\n      tl.to(\n        innerTarget,\n        {\n          duration: speed * 0.75,\n          ease: \"power1.out\",\n          opacity: 1,\n          y: 0,\n        },\n        speed * 0.575\n      );\n\n      timelineRef.current = tl;\n    } else {\n      // Content text fade out first\n      const tl = gsap.timeline({\n        onComplete: () => {\n          setIsVisible(false);\n        },\n      });\n\n      tl.to(innerTarget, {\n        duration: speed * 0.4,\n        ease: \"power1.in\",\n        opacity: 0,\n        y: 8,\n      });\n\n      // Shape morph back: rectangle → circle\n      const targets = [filteredTarget, unfilteredTarget].filter(Boolean);\n      tl.to(\n        targets,\n        {\n          borderRadius: triggerRadius,\n          duration: speed,\n          ease: \"power1.in\",\n          height: triggerSize,\n          width: triggerSize,\n          x: 0,\n          y: 0,\n        },\n        speed * 0.2\n      );\n\n      // Fade out at the end\n      tl.to(\n        targets,\n        {\n          duration: speed * 0.3,\n          ease: \"power1.in\",\n          opacity: 0,\n        },\n        `-=${speed * 0.3}`\n      );\n\n      timelineRef.current = tl;\n    }\n\n    return () => {\n      if (timelineRef.current) {\n        timelineRef.current.kill();\n      }\n    };\n  }, [\n    isOpen,\n    contentHeight,\n    contentWidth,\n    triggerSize,\n    triggerRadius,\n    contentLeft,\n    translateY,\n    speed,\n    prefersReducedMotion,\n  ]);\n\n  const defaultTriggerIcon = (\n    <svg\n      fill=\"none\"\n      height={20}\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      viewBox=\"0 0 24 24\"\n      width={20}\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <line x1=\"12\" x2=\"12\" y1=\"5\" y2=\"19\" />\n      <line x1=\"5\" x2=\"19\" y1=\"12\" y2=\"12\" />\n    </svg>\n  );\n\n  return (\n    <div className={cn(\"relative inline-flex\", className)} ref={containerRef}>\n      {/* SVG goo filter definition */}\n      <svg\n        aria-hidden=\"true\"\n        className=\"absolute\"\n        style={{ height: 0, width: 0 }}\n      >\n        <defs>\n          <filter id={filterId}>\n            <feGaussianBlur\n              in=\"SourceGraphic\"\n              result=\"blur\"\n              stdDeviation={GOO_STD_DEVIATION}\n            />\n            <feColorMatrix\n              in=\"blur\"\n              result=\"goo\"\n              type=\"matrix\"\n              values={`1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 ${String(GOO_MATRIX_ALPHA_MULTIPLIER)} ${String(GOO_MATRIX_ALPHA_OFFSET)}`}\n            />\n            <feComposite in=\"SourceGraphic\" in2=\"goo\" operator=\"atop\" />\n          </filter>\n        </defs>\n      </svg>\n\n      {/* Hidden measurement div */}\n      <div\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute\"\n        ref={measureRef}\n        style={{\n          left: -9999,\n          position: \"absolute\",\n          top: -9999,\n          visibility: \"hidden\",\n          width: contentWidth,\n        }}\n      >\n        <div className={cn(\"p-4\", contentClassName)}>{children}</div>\n      </div>\n\n      {/* Filtered layer: SVG goo filter creates liquid bridge between blob and content */}\n      {!prefersReducedMotion && (isOpen || isVisible) && (\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0\"\n          style={{ filter: `url(#${filterId})` }}\n        >\n          {/* Blob: circle at trigger position — stays put while content morphs away */}\n          <div\n            className={cn(\"absolute rounded-full\", bgClassName)}\n            style={{\n              height: triggerSize,\n              left: 0,\n              top: 0,\n              width: triggerSize,\n            }}\n          />\n\n          {/* Content shape: morphs from circle to rectangle (borderRadius=0, goo softens edges) */}\n          <div\n            className={cn(\"absolute\", bgClassName)}\n            ref={filteredContentRef}\n            style={{\n              borderRadius: triggerRadius,\n              height: triggerSize,\n              left: 0,\n              opacity: 0,\n              top: 0,\n              width: triggerSize,\n            }}\n          />\n        </div>\n      )}\n\n      {/* Trigger button (z-10, sits on top of filtered blob) */}\n      <button\n        aria-expanded={isOpen}\n        aria-haspopup=\"dialog\"\n        className={cn(\n          \"relative z-10 flex cursor-pointer items-center justify-center rounded-full text-white transition-colors\",\n          bgClassName\n        )}\n        onClick={() => setIsOpen(!isOpen)}\n        style={{\n          height: triggerSize,\n          width: triggerSize,\n        }}\n        type=\"button\"\n      >\n        {trigger ?? defaultTriggerIcon}\n      </button>\n\n      {/* Unfiltered content panel (z-10, clean edges, actual text) */}\n      {isOpen || isVisible ? (\n        <div\n          className={cn(\n            \"absolute z-10 overflow-hidden text-white\",\n            bgClassName\n          )}\n          ref={unfilteredContentRef}\n          role=\"dialog\"\n          style={{\n            borderRadius: triggerRadius,\n            height: triggerSize,\n            left: 0,\n            opacity: 0,\n            top: 0,\n            width: triggerSize,\n          }}\n        >\n          <div\n            className={cn(\"p-4\", contentClassName)}\n            ref={innerContentRef}\n            style={{ opacity: 0, transform: \"translateY(16px)\" }}\n          >\n            {children}\n          </div>\n        </div>\n      ) : null}\n    </div>\n  );\n}\n","path":"index.tsx","target":"components/smoothui/gooey-popover/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  }, [ref, handler, eventType]);\n}\n","path":"use-click-outside.tsx","target":"components/smoothui/gooey-popover/use-click-outside.tsx","type":"registry:ui"}],"name":"gooey-popover","registryDependencies":[],"title":"Gooey Popover","type":"registry:ui"}