How to build a magnetic CTA button with particle burst in React
A magnetic button in React offsets itself toward the cursor using useMotionValue and useSpring, when the pointer enters a ~120px radius, dx/dy are measured from the button center and multiplied by 0.28 to create a subtle pull. On hover, 8 particles burst outward in a circle via AnimatePresence, each animated along a pre-computed angle with a staggered delay.
- Stack: React + Framer Motion 11 + lucide-react, ~113 lines, zero other dependencies.
- Core APIs: useMotionValue, useSpring, AnimatePresence, useCallback for stable handlers.
- Accessible: particles are aria-hidden, pulsing rings are aria-hidden, button text is readable.
- Mouse-tracking spotlight uses a static radial gradient centered in the section, not a JS-driven inline style update, the parallax is decorative, not pointer-precise.
- Magnetic pull is disabled automatically when the cursor leaves (spring returns to 0,0).
CTA Magnetic Burst is a centered call-to-action section built around a single high-intent button that physically pulls toward the cursor, then explodes with particles on hover. Two persistent pulsing rings keep the button alive when the mouse is elsewhere. A radial spotlight and a deep blurred glow give the section atmosphere without relying on images.
Anatomy
The section has four layers stacked with position:absolute. At the back sits a deep static glow: a large blurred circle at 5% opacity. Above it, a motion.div holds the radial gradient spotlight (static center, decorative only). The content layer holds the h2, description paragraph and the MagneticButton component. The button itself has two pulsing ring spans (aria-hidden), an AnimatePresence-gated set of 8 BurstParticle spans, and the label + ArrowRight icon in relative z-index 1.
How it works
Magnetic pull is measured on every mousemove: getBoundingClientRect gives the button center, then dx = clientX - centerX and dy = clientY - centerY. If the hypotenuse is below 120px, x.set(dx * 0.28) and y.set(dy * 0.28), the 0.28 factor keeps the pull subtle. Those raw values feed two useSpring instances (stiffness 180, damping 14, mass 0.08) for the translate, so the button glides rather than snaps. The burst fires inside handleHoverStart: state is reset to false, then immediately flipped to true inside a requestAnimationFrame so AnimatePresence unmounts and remounts the 8 particles on every hover, even rapid re-entries. Each BurstParticle animates opacity [0,1,0], scale [0,1.4,0] and translates to a pre-computed (tx,ty) from a unit-circle formula over 0.55s.
How to build it in React
Pre-compute particle positions
Generate the 8 particle targets once at module level, not inside the component, to avoid recomputation on each render. Divide a full circle (2π) into 8 equal angles and project each onto a radius of 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 }; });Wire the magnetic spring
Create two motion values for x and y, then pass them through useSpring with a low mass so the trailing feels snappy. In the mousemove handler, only apply the pull when the cursor is within 120px of the button center, outside that radius the spring stays at rest.
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); } };Trigger the burst via AnimatePresence
The trick to re-firing particles on rapid re-hovers is resetting state to false and back to true inside a requestAnimationFrame, this forces React to unmount the previous particles before mounting a fresh set. Wrap the mapped particles in AnimatePresence so exit animations run.
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>Add pulsing rings for resting state
Two motion spans share the same ring style (position:absolute, inset:-3, border accent, border-radius full). Each loops indefinitely scaling from 1 to 1.22 while fading out, offset by 0.8s from each other, the phase difference creates a continuous breathing feel without requiring any state.
<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 }} />
When to use it
Use this section as the last call to action before the footer on agency sites, SaaS marketing pages or portfolio pieces where one conversion matters more than many. The magnetic pull and particle burst are designed to create a memorable final impression, not to compete with inline product CTAs higher on the page. Skip it on e-commerce checkout flows, dashboards or any page where the button needs to be immediately obvious without requiring a hover discovery. The magnetic effect requires a pointer device, on touch screens the button still works but without the physics.
Used by
- Stripe, Uses magnetic-pull micro-interactions on key CTA buttons across its marketing pages to increase perceived interactivity.
- Linear, Employs particle and glow effects on primary action buttons throughout its product marketing site.
- Resend, Combines pulsing rings and accent glows on CTA buttons to draw attention without heavy animation.
FAQ
Does the magnetic effect work on mobile?
The button remains fully functional on touch screens, but there is no pointer to trigger the magnetic pull or the particle burst. The pulsing rings still animate, keeping the button visually alive.
Why use requestAnimationFrame to re-trigger the burst?
React batches state updates within the same frame, so setting hovered to false then true synchronously results in no re-render. The rAF call flushes the false update first, causing AnimatePresence to unmount the previous particles; the next frame mounts a fresh set with clean initial state.
Can I reduce the spring mass to make the pull snappier?
Yes. The current config is stiffness:180, damping:14, mass:0.08. Dropping mass to 0.04 makes the button track the cursor almost instantly; raising it above 0.2 produces a heavier, more dramatic lag. Adjust damping together with mass, too little damping at very low mass creates oscillation.
How do I respect prefers-reduced-motion?
Read the media query with a useReducedMotion hook (Framer Motion exports one). When it returns true, skip the particle burst, disable the magnetic offset and stop the pulsing ring animations by setting repeat to 0.