Cómo crear un botón CTA magnético con estallido de partículas en React
Un botón magnético en React se desplaza hacia el cursor mediante useMotionValue y useSpring: cuando el puntero entra en un radio de ~120px, dx/dy se miden desde el centro del botón y se multiplican por 0.28 para crear una atracción sutil. Al pasar el cursor, 8 partículas estallan en círculo mediante AnimatePresence, cada una animada según un ángulo precalculado con un retardo escalonado.
- Stack: React + Framer Motion 11 + lucide-react, ~113 líneas, cero dependencias adicionales.
- APIs clave: useMotionValue, useSpring, AnimatePresence, useCallback para handlers estables.
- Accesible: las partículas son aria-hidden, los anillos pulsantes también, el texto del botón es legible.
- El spotlight de seguimiento del ratón usa un degradado radial estático centrado en la sección, sin actualización inline controlada por JS, el parallax es decorativo, no preciso al píxel.
- La atracción magnética se desactiva automáticamente cuando el cursor sale (el spring vuelve a 0,0).
CTA Magnetic Burst es una sección call-to-action centrada construida en torno a un único botón de alta intención que atrae físicamente al cursor y luego estalla en partículas al pasar el ratón. Dos anillos pulsantes persistentes mantienen el botón vivo cuando el ratón está en otro lugar. Un spotlight radial y un resplandor difuminado profundo dan atmósfera a la sección sin recurrir a imágenes.
Anatomía
La sección tiene cuatro capas apiladas con position:absolute. Al fondo se sitúa un resplandor estático profundo: un gran círculo difuminado al 5% de opacidad. Encima, un motion.div contiene el spotlight de degradado radial (centro estático, solo decorativo). La capa de contenido contiene el h2, el párrafo de descripción y el componente MagneticButton. El propio botón tiene dos spans de anillos pulsantes (aria-hidden), un conjunto de 8 spans BurstParticle controlados por AnimatePresence, y la etiqueta + el icono ArrowRight en z-index 1 relativo.
Cómo funciona
La atracción magnética se mide en cada mousemove: getBoundingClientRect da el centro del botón, luego dx = clientX - centerX y dy = clientY - centerY. Si la hipotenusa es inferior a 120px, x.set(dx * 0.28) e y.set(dy * 0.28), el factor 0.28 mantiene la atracción sutil. Esos valores en bruto alimentan dos instancias useSpring (stiffness 180, damping 14, mass 0.08) para el translate, de modo que el botón se desliza en lugar de saltar. El estallido se dispara dentro de handleHoverStart: el estado se reinicia a false y luego se cambia inmediatamente a true dentro de un requestAnimationFrame para que AnimatePresence desmonte y vuelva a montar las 8 partículas en cada hover, incluso en reentradas rápidas. Cada BurstParticle anima opacity [0,1,0], scale [0,1.4,0] y se traslada a un (tx,ty) precalculado mediante la fórmula del círculo unitario en 0.55s.
Cómo crearlo en React
Precalcular las posiciones de las partículas
Genera los 8 objetivos de partículas una sola vez a nivel de módulo, no dentro del componente, para evitar el recálculo en cada render. Divide un círculo completo (2π) en 8 ángulos iguales y proyecta cada uno sobre un radio de 60px.
const BURST_COUNT = 8; const BURST_RADIUS = 60; const burstParticles = Array.from({ length: BURST_COUNT }, (_, i) => { const angle = (i / BURST_COUNT) * Math.PI * 2; return { id: i, tx: Math.cos(angle) * BURST_RADIUS, ty: Math.sin(angle) * BURST_RADIUS }; });Conectar el spring magnético
Crea dos motion values para x e y, luego pásalos por useSpring con una masa baja para que el desplazamiento sea ágil. En el handler mousemove, aplica la atracción solo cuando el cursor está dentro de los 120px del centro del botón, fuera de ese radio el spring permanece en reposo.
const x = useMotionValue(0); const y = useMotionValue(0); const springX = useSpring(x, { stiffness: 180, damping: 14, mass: 0.08 }); const springY = useSpring(y, { stiffness: 180, damping: 14, mass: 0.08 }); const handleMouseMove = (e: React.MouseEvent) => { const rect = btnRef.current!.getBoundingClientRect(); const dx = e.clientX - (rect.left + rect.width / 2); const dy = e.clientY - (rect.top + rect.height / 2); if (Math.hypot(dx, dy) < 120) { x.set(dx * 0.28); y.set(dy * 0.28); } };Disparar el estallido mediante AnimatePresence
El truco para volver a disparar las partículas en cada hover rápido es reiniciar el estado a false y de nuevo a true dentro de un requestAnimationFrame, así React desmonta las partículas previas antes de montar un conjunto nuevo. Envuelve las partículas mapeadas en AnimatePresence para que se ejecuten las animaciones de salida.
const handleHoverStart = useCallback(() => { setHovered(false); requestAnimationFrame(() => setHovered(true)); }, []); // In JSX: <AnimatePresence> {hovered && burstParticles.map((p) => <BurstParticle key={`${p.id}-${String(hovered)}`} tx={p.tx} ty={p.ty} delay={p.id * 0.04} /> )} </AnimatePresence>Añadir anillos pulsantes para el estado de reposo
Dos spans motion comparten el mismo estilo de anillo (position:absolute, inset:-3, borde accent, border-radius full). Cada uno se repite indefinidamente pasando de scale 1 a 1.22 mientras se desvanece, desfasado 0.8s uno respecto al otro, la diferencia de fase crea un efecto de respiración continua sin necesitar ningún estado.
<motion.span aria-hidden style={ringStyle} animate={{ scale: [1, 1.22], opacity: [0.6, 0] }} transition={{ duration: 1.6, repeat: Infinity, ease: "easeOut" }} /> <motion.span aria-hidden style={ringStyle} animate={{ scale: [1, 1.22], opacity: [0.4, 0] }} transition={{ duration: 1.6, repeat: Infinity, ease: "easeOut", delay: 0.8 }} />
Cuándo usarlo
Usa esta sección como última llamada a la acción antes del footer en sitios de agencias, páginas de marketing SaaS o portfolios donde una sola conversión importa más que varias. La atracción magnética y el estallido de partículas están diseñados para crear una impresión final memorable, no para competir con los CTA de producto inline más arriba en la página. Evítala en los flujos de checkout de e-commerce, los dashboards o cualquier página donde el botón deba ser inmediatamente evidente sin requerir un descubrimiento al pasar el cursor. El efecto magnético requiere un dispositivo apuntador, en pantallas táctiles el botón sigue funcionando pero sin la física.
Usado por
- Stripe, Usa microinteracciones de atracción magnética en los botones CTA clave de sus páginas de marketing para aumentar la percepción de interactividad.
- Linear, Emplea efectos de partículas y resplandor en los botones de acción principales de todo su sitio de marketing de producto.
- Resend, Combina anillos pulsantes y resplandores de acento en los botones CTA para captar la atención sin animación pesada.
FAQ
¿El efecto magnético funciona en móvil?
El botón sigue siendo totalmente funcional en pantallas táctiles, pero no hay puntero para disparar la atracción magnética ni el estallido de partículas. Los anillos pulsantes siguen animándose, lo que mantiene el botón visualmente activo.
¿Por qué usar requestAnimationFrame para volver a disparar el estallido?
React agrupa las actualizaciones de estado dentro del mismo frame, así que poner hovered a false y luego a true de forma síncrona no produce ningún re-render. La llamada rAF descarga primero la actualización false, lo que hace que AnimatePresence desmonte las partículas previas; el siguiente frame monta un conjunto nuevo con un estado inicial limpio.
¿Puedo reducir la masa del spring para que la atracción sea más ágil?
Sí. La configuración actual es stiffness:180, damping:14, mass:0.08. Bajar la masa a 0.04 hace que el botón siga el cursor casi al instante; subirla por encima de 0.2 produce un retardo más pesado y dramático. Ajusta damping junto con mass, un damping demasiado bajo con una masa muy reducida genera oscilaciones.
¿Cómo respeto prefers-reduced-motion?
Lee la media query con un hook useReducedMotion (Framer Motion exporta uno). Cuando devuelve true, omite el estallido de partículas, desactiva el desplazamiento magnético y detén las animaciones de los anillos pulsantes poniendo repeat a 0.