{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "spring-tooltip",
  "type": "registry:component",
  "title": "Spring Tooltip",
  "description": "A tooltip (role tooltip) that follows the cursor on a spring and hangs from it like a tag: it tilts with speed and stretches a little as it moves. It also appears on keyboard focus.",
  "author": "Marcelo Treviño Velázquez <https://furin.dev>",
  "dependencies": [],
  "categories": [
    "Navigation"
  ],
  "docs": "Installed in components/furin/spring-tooltip/. Docs, live controls and props table: https://furin.dev/components/spring-tooltip/",
  "files": [
    {
      "path": "registry/spring-tooltip/SpringTooltip.tsx",
      "content": "\"use client\";\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport type { CSSProperties, ReactNode } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport \"./SpringTooltip.css\";\n\nexport interface SpringTooltipProps {\n  /** Contenido del tooltip. */\n  content?: ReactNode;\n  /** Elemento sobre el que aparece. Si no pones nada, se dibuja uno de ejemplo. */\n  children?: ReactNode;\n  /** Textos del elemento de ejemplo (solo se ven si omites children). */\n  sample?: { title?: string; text?: string };\n  /** Qué tanto se inclina con la velocidad del cursor (0 = no se inclina). */\n  tilt?: number;\n  /** Rigidez del resorte con el que sigue al cursor. */\n  stiffness?: number;\n  /** Distancia al cursor, en px. */\n  offset?: number;\n  /** Milisegundos de espera antes de mostrarse. */\n  delay?: number;\n  /** Se pasea solo sobre el elemento cuando nadie lo toca. */\n  autoplay?: boolean;\n  /** Clase extra para el contenedor. */\n  className?: string;\n  /** Estilos extra para el contenedor. */\n  style?: CSSProperties;\n}\n\nconst FOCUSABLE = \"a[href], button, input, select, textarea, [tabindex]:not([tabindex='-1'])\";\n\n/** Tooltip con física: sigue al cursor con un resorte y cuelga de él como una etiqueta, así que se inclina con la velocidad. */\nexport function SpringTooltip({\n  content = \"It tilts with the cursor's speed\",\n  children,\n  sample,\n  tilt = 1,\n  stiffness = 360,\n  offset = 16,\n  delay = 0,\n  autoplay = false,\n  className = \"\",\n  style,\n}: SpringTooltipProps) {\n  const muestra = { title: \"Hover here\", text: \"and move fast to watch it swing\", ...sample };\n  const id = \"furin-tip-\" + useId().replace(/[^a-zA-Z0-9]/g, \"\");\n  const wrap = useRef<HTMLSpanElement>(null);\n  const globo = useRef<HTMLDivElement>(null);\n  const [presente, setPresente] = useState(false);\n  const [montado, setMontado] = useState(false);\n  const live = useRef({ tilt, stiffness, offset, delay, autoplay });\n  live.current = { tilt, stiffness, offset, delay, autoplay };\n  // Estado de la simulación: posición del ancla, ángulo del péndulo, escala de entrada.\n  const sim = useRef({ x: 0, y: 0, vx: 0, vy: 0, tx: 0, ty: 0, ang: 0, vang: 0, esc: 0.5, vesc: 0, abierto: false, raf: 0, last: 0, placed: false, pvx: 0, lastX: 0, lastT: 0, modo: \"cursor\" as \"cursor\" | \"foco\", escapado: false, timer: 0, real: false, lastReal: -1e9, visible: true, autoOn: false });\n  const tick = useRef<(t: number) => void>(() => {});\n\n  useEffect(() => setMontado(true), []);\n\n  const arranca = useCallback(() => {\n    const s = sim.current;\n    if (!s.raf) { s.last = performance.now(); s.raf = requestAnimationFrame((t) => tick.current(t)); }\n  }, []);\n\n  // Enlaza el tooltip con el control para lectores de pantalla.\n  const describe = useCallback((on: boolean) => {\n    const w = wrap.current;\n    if (!w) return;\n    const f = (w.matches(FOCUSABLE) ? w : w.querySelector(FOCUSABLE)) as HTMLElement | null;\n    if (!f) return;\n    if (on) f.setAttribute(\"aria-describedby\", id);\n    else if (f.getAttribute(\"aria-describedby\") === id) f.removeAttribute(\"aria-describedby\");\n  }, [id]);\n\n  tick.current = (nowMs: number) => {\n    const s = sim.current, g = globo.current;\n    s.raf = 0;\n    const dt = Math.min(0.032, Math.max(0.001, (nowMs - s.last) / 1000));\n    s.last = nowMs;\n    const L = live.current;\n    const reduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    // Autoplay: un cursor virtual recorre el elemento cuando nadie lo toca.\n    const auto = L.autoplay && s.visible && !s.real && !s.escapado && nowMs - s.lastReal > 2500 && !!wrap.current;\n    if (auto && wrap.current) {\n      const r = wrap.current.getBoundingClientRect();\n      const t = nowMs / 1000;\n      s.tx = r.left + r.width * (0.5 + 0.34 * Math.sin(t * 1.1)) + L.offset;\n      s.ty = r.top + r.height * (0.5 + 0.28 * Math.sin(t * 1.7 + 0.5)) + L.offset;\n      if (!s.abierto) {\n        s.modo = \"cursor\"; s.abierto = true; s.autoOn = true;\n        if (!s.placed || s.esc < 0.65) { s.x = s.tx; s.y = s.ty; s.vx = s.vy = 0; s.placed = true; }\n        setPresente(true);\n      }\n    } else if (s.autoOn) {\n      s.autoOn = false;\n      if (!s.real) s.abierto = false;\n    }\n    // Aún sin globo en el DOM (se acaba de pedir): espera al siguiente cuadro.\n    if (!g) { if (s.abierto || auto || (L.autoplay && s.visible)) s.raf = requestAnimationFrame((t2) => tick.current(t2)); return; }\n    // Posición: resorte rígido con poco rebote.\n    const k = L.stiffness, c = 2 * Math.sqrt(k) * 0.82;\n    const objetivoX = s.tx, objetivoY = s.ty;\n    const h = dt / 2;\n    for (let i = 0; i < 2; i++) {\n      s.vx += (k * (objetivoX - s.x) - c * s.vx) * h;\n      s.x += s.vx * h;\n      s.vy += (k * (objetivoY - s.y) - c * s.vy) * h;\n      s.y += s.vy * h;\n    }\n    // Péndulo: la etiqueta cuelga del cursor; el ángulo objetivo sale de la velocidad horizontal del ancla.\n    const kAng = 120, cAng = 2 * Math.sqrt(kAng) * 0.3;\n    const objetivoAng = reduce ? 0 : Math.max(-26, Math.min(26, s.vx * 0.016 * L.tilt));\n    s.vang += (kAng * (objetivoAng - s.ang) - cAng * s.vang) * dt;\n    s.ang += s.vang * dt;\n    // Entrada y salida: escala con resorte flojo (rebota un poco).\n    const escObj = s.abierto ? 1 : 0.6;\n    s.vesc += ((escObj - s.esc) * 300 - s.vesc * 17) * dt;\n    s.esc += s.vesc * dt;\n    const speed = Math.hypot(s.vx, s.vy);\n    const squash = reduce ? 0 : Math.min(0.12, speed * 0.00018 * L.tilt);\n    // El globo no se sale de la ventana: si no cabe a la derecha o abajo, se pone al otro lado del cursor y, si aun así no cabe, se recorta al borde.\n    const w = g.offsetWidth, hh = g.offsetHeight;\n    const iw = window.innerWidth, ih = window.innerHeight;\n    let ax = s.x, ay = s.y, ox: number, oy: number;\n    if (s.modo === \"cursor\") {\n      const cx = s.x - L.offset, cy = s.y - L.offset;\n      if (ax + w > iw - 8) ax = cx - L.offset - w;\n      if (ay + hh > ih - 8) ay = cy - L.offset - hh;\n      ax = Math.max(8, Math.min(ax, iw - w - 8));\n      ay = Math.max(8, Math.min(ay, ih - hh - 8));\n      // El pivote del giro es el punto del cursor, por eso queda fuera del globo.\n      ox = cx - ax; oy = cy - ay;\n    } else {\n      ax = Math.max(8, Math.min(ax, iw - w - 8));\n      ox = s.x - ax + w / 2; oy = -10;\n    }\n    g.style.transformOrigin = `${ox.toFixed(1)}px ${oy.toFixed(1)}px`;\n    g.style.transform = `translate3d(${ax.toFixed(2)}px, ${ay.toFixed(2)}px, 0) rotate(${s.ang.toFixed(3)}deg) scale(${(Math.max(s.esc, 0.01) * (1 + squash)).toFixed(3)}, ${(Math.max(s.esc, 0.01) * (1 - squash * 0.8)).toFixed(3)})`;\n    g.style.opacity = String(Math.max(0, Math.min(1, (s.esc - 0.6) / 0.4)).toFixed(3));\n    const quieto = Math.abs(objetivoX - s.x) < 0.1 && Math.abs(objetivoY - s.y) < 0.1 && Math.abs(s.vx) < 0.5 && Math.abs(s.vy) < 0.5 && Math.abs(s.ang) < 0.05 && Math.abs(s.vang) < 0.1 && Math.abs(escObj - s.esc) < 0.003 && Math.abs(s.vesc) < 0.02;\n    if (!quieto || auto || (L.autoplay && s.visible && !s.real)) { s.raf = requestAnimationFrame((t2) => tick.current(t2)); return; }\n    if (!s.abierto) setPresente(false);\n  };\n\n  const abrir = useCallback((modo: \"cursor\" | \"foco\", x: number, y: number) => {\n    const s = sim.current;\n    window.clearTimeout(s.timer);\n    const go = () => {\n      s.modo = modo;\n      s.abierto = true;\n      const o = live.current.offset;\n      if (!s.placed || !globo.current?.classList.contains(\"is-on\")) { s.x = x + (modo === \"cursor\" ? o : 0); s.y = y + (modo === \"cursor\" ? o : 0); s.vx = s.vy = 0; s.ang = s.vang = 0; s.placed = true; }\n      s.tx = x + (modo === \"cursor\" ? o : 0);\n      s.ty = y + (modo === \"cursor\" ? o : 0);\n      setPresente(true);\n      describe(true);\n      arranca();\n    };\n    if (live.current.delay > 0 && !s.abierto) s.timer = window.setTimeout(go, live.current.delay);\n    else go();\n  }, [arranca, describe]);\n\n  const cerrar = useCallback(() => {\n    const s = sim.current;\n    window.clearTimeout(s.timer);\n    s.abierto = false;\n    describe(false);\n    arranca();\n  }, [arranca, describe]);\n\n  // Clase que marca que el globo ya está visible, para decidir si se reubica al abrir.\n  useEffect(() => {\n    const g = globo.current;\n    if (g) g.classList.toggle(\"is-on\", presente);\n    if (presente) arranca();\n  }, [presente, arranca, montado]);\n\n  useEffect(() => {\n    const w = wrap.current;\n    if (!w) return;\n    const s = sim.current;\n    const onMove = (e: PointerEvent) => {\n      if (e.pointerType === \"touch\") return;\n      s.escapado = false; s.real = true; s.lastReal = performance.now(); s.autoOn = false;\n      const o = live.current.offset;\n      if (s.abierto && s.modo === \"cursor\") { s.tx = e.clientX + o; s.ty = e.clientY + o; arranca(); }\n      else abrir(\"cursor\", e.clientX, e.clientY);\n    };\n    const onLeave = () => { s.real = false; s.lastReal = performance.now(); if (s.modo === \"cursor\") cerrar(); };\n    const onFocusIn = (e: FocusEvent) => {\n      if (!(e.target as HTMLElement).matches?.(\":focus-visible\")) return;\n      s.escapado = false;\n      const r = (e.target as HTMLElement).getBoundingClientRect();\n      abrir(\"foco\", r.left + r.width / 2, r.bottom + 10);\n      if (globo.current) s.tx -= globo.current.offsetWidth / 2;\n    };\n    const onFocusOut = () => cerrar();\n    const onKey = (e: KeyboardEvent) => { if (e.key === \"Escape\" && s.abierto) { s.escapado = true; cerrar(); } };\n    w.addEventListener(\"pointermove\", onMove);\n    w.addEventListener(\"pointerleave\", onLeave);\n    w.addEventListener(\"focusin\", onFocusIn);\n    w.addEventListener(\"focusout\", onFocusOut);\n    window.addEventListener(\"keydown\", onKey);\n    return () => {\n      window.removeEventListener(\"keydown\", onKey);\n      w.removeEventListener(\"pointermove\", onMove);\n      w.removeEventListener(\"pointerleave\", onLeave);\n      w.removeEventListener(\"focusin\", onFocusIn);\n      w.removeEventListener(\"focusout\", onFocusOut);\n      window.clearTimeout(s.timer);\n      cancelAnimationFrame(s.raf);\n      s.raf = 0;\n    };\n  }, [abrir, cerrar, arranca]);\n\n  // Demo automática: el cursor virtual solo corre mientras el elemento está a la vista.\n  useEffect(() => {\n    const w = wrap.current;\n    if (!autoplay || !w) return;\n    const s = sim.current;\n    const io = new IntersectionObserver(([en]) => { s.visible = en.isIntersecting; if (s.visible) arranca(); });\n    io.observe(w);\n    return () => io.disconnect();\n  }, [autoplay, arranca]);\n\n  const tip = presente ? (\n    <div ref={globo} id={id} role=\"tooltip\" className=\"furin-spring-tooltip__globo\">\n      <span className=\"furin-spring-tooltip__punto\" aria-hidden=\"true\" />\n      {content}\n    </div>\n  ) : null;\n\n  return (\n    <span ref={wrap} className={`furin-spring-tooltip ${className}`} style={style}>\n      {children ?? (\n        <button type=\"button\" className=\"furin-spring-tooltip__ejemplo\">\n          <span className=\"furin-spring-tooltip__etq\">{muestra.title}</span>\n          <span className=\"furin-spring-tooltip__sub\">{muestra.text}</span>\n        </button>\n      )}\n      {montado && tip ? createPortal(tip, document.body) : null}\n    </span>\n  );\n}\n\nexport default SpringTooltip;\n",
      "type": "registry:component",
      "target": "components/furin/spring-tooltip/SpringTooltip.tsx"
    },
    {
      "path": "registry/spring-tooltip/SpringTooltip.css",
      "content": ".furin-spring-tooltip { display: inline-block; font-family: Geist, system-ui, -apple-system, sans-serif; }\n\n.furin-spring-tooltip__ejemplo {\n  display: grid;\n  place-content: center;\n  gap: 8px;\n  width: min(330px, 80vw);\n  height: 150px;\n  appearance: none;\n  border-radius: 20px;\n  border: 1px solid rgba(255, 255, 255, 0.1);\n  background: linear-gradient(160deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.025));\n  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.07), 0 20px 40px -24px rgba(0, 0, 0, 0.8);\n  color: #f2f3f5;\n  font: inherit;\n  text-align: center;\n  cursor: default;\n  transition: border-color 0.3s, background-color 0.3s;\n}\n.furin-spring-tooltip__ejemplo:hover { border-color: rgba(255, 255, 255, 0.2); }\n.furin-spring-tooltip__ejemplo:focus-visible { outline: 2px solid rgba(255, 255, 255, 0.8); outline-offset: 3px; }\n.furin-spring-tooltip__etq { font-size: 20px; font-weight: 600; letter-spacing: -0.025em; }\n.furin-spring-tooltip__sub { font-size: 13px; color: rgba(255, 255, 255, 0.5); }\n\n/* Globo: se coloca por JS con transform; aquí solo su apariencia. */\n.furin-spring-tooltip__globo {\n  position: fixed;\n  left: 0; top: 0;\n  z-index: 2147483600;\n  display: flex;\n  align-items: center;\n  gap: 9px;\n  max-width: 260px;\n  padding: 9px 13px 9px 11px;\n  border-radius: 11px;\n  background: linear-gradient(180deg, rgba(40, 42, 50, 0.92), rgba(26, 27, 33, 0.94));\n  -webkit-backdrop-filter: blur(14px) saturate(1.4);\n  backdrop-filter: blur(14px) saturate(1.4);\n  box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.13), inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 14px 28px -10px rgba(0, 0, 0, 0.7), 0 3px 8px rgba(0, 0, 0, 0.35);\n  font: 500 13px/1.35 Geist, system-ui, -apple-system, sans-serif;\n  letter-spacing: -0.005em;\n  color: #f4f5f7;\n  pointer-events: none;\n  opacity: 0;\n  will-change: transform, opacity;\n}\n.furin-spring-tooltip__punto { flex: none; width: 6px; height: 6px; border-radius: 50%; background: #8ea2ff; box-shadow: 0 0 10px #8ea2ff; }\n",
      "type": "registry:file",
      "target": "components/furin/spring-tooltip/SpringTooltip.css"
    }
  ]
}
