{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cursor-bubble",
  "title": "Cursor Bubble",
  "description": "A floating label that follows the cursor and elastic-pops into view over interactive targets, powered by GSAP.",
  "dependencies": ["gsap", "@gsap/react"],
  "registryDependencies": ["@soralabs/hooks-use-prefers-reduced-motion"],
  "files": [
    {
      "path": "registry/primitives/effects/cursor-bubble/index.tsx",
      "content": "/** biome-ignore-all lint/a11y/noNoninteractiveElementInteractions: The target wraps its own interactive child; the span only needs hover affordances. */\n/** biome-ignore-all lint/a11y/noStaticElementInteractions: Same as above — hover-only, no keyboard/click semantics of its own. */\n\n\"use client\";\n\nimport { useGSAP } from \"@gsap/react\";\nimport { cn } from \"@/lib/utils\";\nimport gsap from \"gsap\";\nimport {\n  type ComponentPropsWithoutRef,\n  createContext,\n  type ReactNode,\n  useContext,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { usePrefersReducedMotion } from \"@/hooks/use-prefers-reduced-motion\";\n\nconst DEFAULT_LABEL = \"click\";\nconst CURSOR_OFFSET_X = 13;\nconst CURSOR_OFFSET_Y = -43;\nconst FOLLOW_DURATION = 0.5;\nconst POP_IN_DURATION = 1.7;\nconst POP_IN_DELAY = 0.1;\nconst POP_OUT_DURATION = 0.3;\nconst RESTING_ROTATION = -30;\n\ninterface CursorBubbleContextValue {\n  hide: () => void;\n  show: (label: string) => void;\n}\n\nconst CursorBubbleContext = createContext<CursorBubbleContextValue | null>(\n  null\n);\n\nfunction useCursorBubbleContext() {\n  const context = useContext(CursorBubbleContext);\n\n  if (!context) {\n    throw new Error(\n      \"CursorBubbleTarget must be used within a CursorBubble provider.\"\n    );\n  }\n\n  return context;\n}\n\nfunction useCoarsePointer() {\n  const [isCoarsePointer, setIsCoarsePointer] = useState(false);\n\n  useEffect(() => {\n    const mediaQuery = window.matchMedia(\"(pointer: coarse)\");\n\n    const update = () => {\n      setIsCoarsePointer(mediaQuery.matches);\n    };\n\n    update();\n    mediaQuery.addEventListener(\"change\", update);\n\n    return () => {\n      mediaQuery.removeEventListener(\"change\", update);\n    };\n  }, []);\n\n  return isCoarsePointer;\n}\n\nexport interface CursorBubbleProps\n  extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\"> {\n  /** Extra classes applied to the floating bubble itself (color, padding, shape, etc). */\n  bubbleClassName?: string;\n  children?: ReactNode;\n}\n\nfunction CursorBubble({\n  bubbleClassName,\n  children,\n  className,\n  ...props\n}: CursorBubbleProps) {\n  const bubbleRef = useRef<HTMLDivElement>(null);\n  const showRef = useRef<(label: string) => void>(() => undefined);\n  const hideRef = useRef<() => void>(() => undefined);\n  const prefersReducedMotion = usePrefersReducedMotion();\n  const isCoarsePointer = useCoarsePointer();\n  const disabled = prefersReducedMotion || isCoarsePointer;\n\n  useGSAP(\n    () => {\n      const bubble = bubbleRef.current;\n      if (!bubble || disabled) {\n        return;\n      }\n\n      const xTo = gsap.quickTo(bubble, \"x\", {\n        duration: FOLLOW_DURATION,\n        ease: \"power3\",\n      });\n      const yTo = gsap.quickTo(bubble, \"y\", {\n        duration: FOLLOW_DURATION,\n        ease: \"power3\",\n      });\n\n      gsap.set(bubble, { rotation: RESTING_ROTATION });\n\n      showRef.current = (label: string) => {\n        bubble.textContent = label;\n        gsap.killTweensOf(bubble, \"opacity,scale,rotation\");\n        gsap.to(bubble, {\n          duration: POP_IN_DURATION,\n          delay: POP_IN_DELAY,\n          ease: \"elastic.out(1, 0.4)\",\n          opacity: 1,\n          rotation: 0,\n          scale: 1,\n        });\n      };\n\n      hideRef.current = () => {\n        gsap.killTweensOf(bubble, \"opacity,scale,rotation\");\n        gsap.to(bubble, {\n          duration: POP_OUT_DURATION,\n          ease: \"sine.inOut\",\n          opacity: 1,\n          rotation: RESTING_ROTATION,\n          scale: 0,\n        });\n      };\n\n      const handlePointerMove = (event: PointerEvent) => {\n        xTo(event.clientX + CURSOR_OFFSET_X);\n        yTo(event.clientY + CURSOR_OFFSET_Y);\n      };\n\n      window.addEventListener(\"pointermove\", handlePointerMove);\n\n      return () => {\n        window.removeEventListener(\"pointermove\", handlePointerMove);\n        showRef.current = () => undefined;\n        hideRef.current = () => undefined;\n      };\n    },\n    { dependencies: [disabled] }\n  );\n\n  const contextValue = useMemo<CursorBubbleContextValue>(\n    () => ({\n      hide: () => hideRef.current(),\n      show: (label: string) => showRef.current(label),\n    }),\n    []\n  );\n\n  return (\n    <CursorBubbleContext.Provider value={contextValue}>\n      <div className={cn(\"contents\", className)} {...props}>\n        {children}\n        {disabled ? null : (\n          <div\n            aria-hidden=\"true\"\n            className={cn(\n              \"pointer-events-none fixed top-0 left-0 z-100 origin-left scale-0 whitespace-nowrap rounded-[50px_50px_50px_0] bg-primary px-[7px] py-[5px] font-medium text-lg text-primary-foreground capitalize opacity-0\",\n              bubbleClassName\n            )}\n            ref={bubbleRef}\n          />\n        )}\n      </div>\n    </CursorBubbleContext.Provider>\n  );\n}\n\nexport interface CursorBubbleTargetProps\n  extends Omit<ComponentPropsWithoutRef<\"span\">, \"children\"> {\n  children?: ReactNode;\n  /** Text shown in the bubble while hovering this target. @default \"click\" */\n  label?: string;\n}\n\nfunction CursorBubbleTarget({\n  children,\n  label = DEFAULT_LABEL,\n  ...props\n}: CursorBubbleTargetProps) {\n  const { hide, show } = useCursorBubbleContext();\n\n  return (\n    <span onMouseEnter={() => show(label)} onMouseLeave={hide} {...props}>\n      {children}\n    </span>\n  );\n}\n\nexport { CursorBubble, CursorBubbleTarget };\n",
      "type": "registry:ui",
      "target": "components/sora-ui/effects/cursor-bubble.tsx"
    }
  ],
  "meta": {
    "keywords": ["cursor", "bubble", "hover", "label", "gsap"],
    "inspiration": {
      "type": "reimplemented",
      "label": "Truus.co",
      "url": "https://www.truus.co/",
      "stack": "GSAP and React"
    }
  },
  "type": "registry:ui"
}
