Tooltip magnético
Persigue al cursor con resorte y aparece tras un breve retraso.
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"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.
npx shadcn@latest add https://www.gassierconsulting.com/r/magnetic-tooltipEjemplo 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.
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".
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.