Ir al contenido
Evaluar mi proyecto
Componentes · MicrointeraccionesDisponible

Tooltip magnético

Persigue al cursor con resorte y aparece tras un breve retraso.

Guardar cambiosCopiar al portapapelesEnviar por correoEliminar elemento

Código completo

Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.

magnetic-tooltip.tsx144 líneas
Descargar
"use client";

/**
 * Tooltip magnético: envuelve cualquier hijo y muestra una etiqueta que persigue al cursor con
 * física de resorte (lerp en requestAnimationFrame). Aparece tras un retraso configurable y se
 * oculta al salir. Con prefers-reduced-motion salta directamente a la posición del cursor.
 *
 *   <MagneticTooltip label="Editar">
 *     <button>...</button>
 *   </MagneticTooltip>
 */

import { useEffect, useRef, useState, type ReactNode } from "react";

const CSS = `
.gmtl-wrap { position: relative; display: inline-block; }
.gmtl-bubble { position: fixed; top: 0; left: 0; z-index: 90; pointer-events: none; padding: 7px 11px; border-radius: 7px; background: #1a1d24; color: #f3f5f8; font: inherit; font-size: 13px; letter-spacing: -.005em; white-space: nowrap; box-shadow: 0 6px 18px -4px rgba(0,0,0,.4), inset 0 0 0 1px rgba(255,255,255,.08); transform: translate3d(-9999px, -9999px, 0); opacity: 0; transition: opacity .18s ease; will-change: transform, opacity; }
.gmtl-bubble[data-open="true"] { opacity: 1; }
.gmtl-bubble::after { content: ""; position: absolute; left: 50%; bottom: -4px; width: 10px; height: 10px; background: inherit; transform: translateX(-50%) rotate(45deg); box-shadow: inset -1px -1px 0 1px rgba(255,255,255,.04); border-radius: 1px; }
@media (prefers-reduced-motion: reduce) {
  .gmtl-bubble { transition: opacity .1s ease; }
}
`;

export function MagneticTooltip({ label, children, delay = 160 }: { label: string; children: ReactNode; delay?: number }) {
  const wrapRef = useRef<HTMLSpanElement>(null);
  const bubbleRef = useRef<HTMLSpanElement>(null);
  const target = useRef({ x: 0, y: 0 });
  const current = useRef({ x: -9999, y: -9999 });
  const rafRef = useRef<number | null>(null);
  const showTimerRef = useRef<number | null>(null);
  const [open, setOpen] = useState(false);

  useEffect(() => {
    if (typeof window === "undefined") return;
    const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const stableDelay = Math.max(0, delay);

    function render() {
      const bubble = bubbleRef.current;
      if (!bubble) return;
      const tx = target.current.x;
      const ty = target.current.y;
      const k = reducedMotion ? 1 : 0.22;
      const nx = current.current.x + (tx - current.current.x) * k;
      const ny = current.current.y + (ty - current.current.y) * k;
      current.current = { x: nx, y: ny };
      bubble.style.transform = `translate3d(${nx}px, ${ny}px, 0) translate(-50%, calc(-100% - 14px))`;
      if (!reducedMotion && Math.abs(nx - tx) + Math.abs(ny - ty) > 0.3) {
        rafRef.current = requestAnimationFrame(render);
      } else {
        rafRef.current = null;
      }
    }

    function startLoop() {
      if (rafRef.current === null) {
        rafRef.current = requestAnimationFrame(render);
      }
    }

    function onEnter(event: PointerEvent) {
      target.current = { x: event.clientX, y: event.clientY };
      current.current = { ...target.current };
      if (showTimerRef.current !== null) window.clearTimeout(showTimerRef.current);
      showTimerRef.current = window.setTimeout(() => setOpen(true), reducedMotion ? 0 : stableDelay);
      startLoop();
    }

    function onMove(event: PointerEvent) {
      target.current = { x: event.clientX, y: event.clientY };
      if (current.current.x === -9999) {
        current.current = { ...target.current };
      }
      startLoop();
    }

    function onLeave() {
      if (showTimerRef.current !== null) {
        window.clearTimeout(showTimerRef.current);
        showTimerRef.current = null;
      }
      setOpen(false);
      startLoop();
    }

    const wrap = wrapRef.current;
    if (!wrap) return;
    wrap.addEventListener("pointermove", onMove);
    wrap.addEventListener("pointerenter", onEnter);
    wrap.addEventListener("pointerleave", onLeave);
    return () => {
      wrap.removeEventListener("pointermove", onMove);
      wrap.removeEventListener("pointerenter", onEnter);
      wrap.removeEventListener("pointerleave", onLeave);
      if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
      if (showTimerRef.current !== null) window.clearTimeout(showTimerRef.current);
    };
  }, [delay]);

  return (
    <>
      <style>{CSS}</style>
      <span ref={wrapRef} className="gmtl-wrap">
        {children}
        <span ref={bubbleRef} className="gmtl-bubble" role="tooltip" data-open={open ? "true" : "false"}>
          {label}
        </span>
      </span>
    </>
  );
}

export function MagneticTooltipDemo() {
  return (
    <div className="lib-stage-row" style={{ gap: 18, flexWrap: "wrap", justifyContent: "center" }}>
      {[
        { label: "Guardar cambios", text: "Guardar" },
        { label: "Copiar al portapapeles", text: "Copiar" },
        { label: "Enviar por correo", text: "Enviar" },
        { label: "Eliminar elemento", text: "Eliminar" },
      ].map((item) => (
        <MagneticTooltip key={item.label} label={item.label}>
          <button
            type="button"
            style={{
              padding: "10px 18px",
              borderRadius: 8,
              border: "1px solid rgba(255,255,255,.14)",
              background: "rgba(255,255,255,.06)",
              color: "#e8eaef",
              font: "inherit",
              fontSize: 14,
              cursor: "pointer",
            }}
          >
            {item.text}
          </button>
        </MagneticTooltip>
      ))}
    </div>
  );
}

Úsalo en tu proyecto

Instálalo con la CLI de shadcn.

terminal.sh1 líneas
npx shadcn@latest add https://www.gassierconsulting.com/r/magnetic-tooltip

Ejemplo de uso de Tooltip magnético

Después de copiar magnetic-tooltip.tsx a tu proyecto, impórtalo y colócalo donde lo necesites. Exporta 2 componentes: MagneticTooltip, MagneticTooltipDemo.

ejemplo.tsx5 líneas
import { MagneticTooltip } from "./magnetic-tooltip";

export default function Ejemplo() {
  return <MagneticTooltip />;
}

Accesibilidad y rendimiento

El archivo tiene 144 líneas (5 KB) y no necesita @keyframes. Esto es lo que hace Tooltip magnético por quien lo usa:

  • Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
  • Declara roles ARIA para lectores de pantalla: tooltip.
  • Reacciona al puntero con eventos pointer (ratón, lápiz y táctil).
  • Anima con requestAnimationFrame, sincronizado con el refresco de pantalla.
  • Es un componente de cliente de Next.js: incluye la directiva "use client".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React · Next.js (quita la directiva use client fuera de Next.js) · Sin Tailwind

Acerca del producto

Envuelve cualquier hijo y muestra una etiqueta que persigue al puntero con un lerp suave (spring physics en JS). Aparece tras 160 ms, se oculta al salir y respeta prefers-reduced-motion desactivando el lerp.

Incluye

Un solo archivo React + TypeScript, con los estilos dentro. No usa Tailwind ni bibliotecas externas.

  • Posición con física de resorte (requestAnimationFrame)
  • Retraso configurable antes de mostrar
  • Sin parpadeo: lerp continuo hacia el cursor
  • Cumple prefers-reduced-motion
  • Una sola burbuja, sin superposiciones con texto

Cómo usarlo

Envuelve cualquier elemento interactivo y pásale label. Cambia delay (ms) para ajustar la espera.