A cursor-magnetic button is one of the most satisfying micro-interactions on the web. The button subtly leans toward the cursor when it's nearby, snaps back when you leave, and reads as "this site is alive."
It's also one of the harder effects to nail. Most tutorials give you a janky version where the button overshoots, jitters, or never quite returns to center. This walk-through covers the pattern that actually works.
The effect
When the cursor is inside the button's bounding box, the button translates toward the cursor by a configurable fraction of the cursor's offset from center. When the cursor leaves, the button eases back to position (0, 0).
The key insight: the magnetism strength is a fraction, not an absolute. If the cursor is 40px from center and the strength is 0.4, the button moves 16px toward it. That fractional relationship is what makes the effect feel natural instead of follow-the-pointer-exactly.
The core math
import type { ComponentType } from "react"
import { useState, useRef } from "react"
import { motion } from "framer-motion"
export function withMagnetic(Component): ComponentType {
return (props) => {
const ref = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState({ x: 0, y: 0 })
const handleMouseMove = (e: React.MouseEvent) => {
if (!ref.current) return
const rect = ref.current.getBoundingClientRect()
const cx = rect.left + rect.width / 2
const cy = rect.top + rect.height / 2
// strength = 0.4 — adjust between 0 and 1 for more/less pull
setPos({ x: (e.clientX - cx) * 0.4, y: (e.clientY - cy) * 0.4 })
}
const handleMouseLeave = () => setPos({ x: 0, y: 0 })
return (
<motion.div
ref={ref}
animate={{ x: pos.x, y: pos.y }}
transition={{ type: "spring", stiffness: 150, damping: 15 }}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
>
<Component {...props} />
</motion.div>
)
}
}
Three things to call out:
getBoundingClientRectgives us the button's center in viewport coordinates.e.clientX / Yis already viewport-relative, so the subtraction is clean.type: "spring"in the framer-motion transition is what makes the return feel natural. Linear or ease-out transitions both feel mechanical. Spring with stiffness ~150 and damping ~15 is a sweet spot — adjust either to taste.onMouseLeaveresets position. Without this, the button stays wherever the cursor was when it left the bounding box.
What's missing from the snippet above
The version above works but has rough edges:
- No bounding margin — currently the magnetism only kicks in inside the button. The slick version triggers in a buffer zone around the button too (e.g. 30px outside).
- No prop control over strength — you'd want
magnetismandreturnEaseas override props you can adjust from Framer's right sidebar. - No accessibility consideration — keyboard focus should bypass the magnetism (it confuses screen readers).
- No reduced-motion fallback —
prefers-reduced-motion: reduceusers should get a static button.
Production-ready Framer overrides need all four. We've packaged the polished version on our marketplace — drop it on a button frame, tweak the props, ship:
OVRMagnetic Button$8→Need a variation?
The Forge generates Framer overrides from natural-language prompts. "Magnetic button but only triggers within 50px of the button" or "magnetic button that also scales 1.05x on hover" — describe it, get the code. Useful when you've hit the limits of what the marketplace version's props expose.