Implementing Magnetic Button Effect on Website
Magnetic Button is a button that attracts toward the cursor when approaching. The element itself doesn't shift toward the mouse—the inner content (text, icon) shifts more than the outer container. This creates an elasticity and physicality effect.
The effect operates on three principles: the attraction zone is wider than the button itself, movement happens with deceleration (spring), and when the cursor leaves, return to original position with elasticity.
Attraction Mathematics
For each frame, distance is calculated from button center to cursor. If cursor is in attraction zone, displacement is computed proportional to distance and normalized to max value.
interface MagneticConfig {
strength: number // attraction force, 0.3–0.6
innerStrength: number // force for inner content, 0.6–1.2
radius: number // attraction zone multiplier from button size
}
class MagneticButton {
private el: HTMLElement
private inner: HTMLElement
private bounds: DOMRect
private config: MagneticConfig
private rafId: number | null = null
// Current displacements (animated)
private xEl = 0
private yEl = 0
private xInner = 0
private yInner = 0
// Target displacements
private targetXEl = 0
private targetYEl = 0
private targetXInner = 0
private targetYInner = 0
private readonly LERP = 0.15
constructor(el: HTMLElement, config: Partial<MagneticConfig> = {}) {
this.el = el
this.inner = el.querySelector('[data-magnetic-inner]') || el
this.config = {
strength: 0.4,
innerStrength: 0.8,
radius: 1.6,
...config,
}
this.bounds = el.getBoundingClientRect()
this.init()
}
private init() {
window.addEventListener('mousemove', this.onMouseMove)
window.addEventListener('resize', this.recalcBounds)
this.tick()
}
private recalcBounds = () => {
this.bounds = this.el.getBoundingClientRect()
}
private onMouseMove = (e: MouseEvent) => {
const { left, top, width, height } = this.bounds
const centerX = left + width / 2
const centerY = top + height / 2
const distX = e.clientX - centerX
const distY = e.clientY - centerY
// Distance in units of "half-width of button"
const distance = Math.sqrt(distX ** 2 + distY ** 2)
const threshold = (Math.max(width, height) / 2) * this.config.radius
if (distance < threshold) {
// In attraction zone — compute displacement
const force = (threshold - distance) / threshold // 0..1
this.targetXEl = distX * this.config.strength * force
this.targetYEl = distY * this.config.strength * force
this.targetXInner = distX * this.config.innerStrength * force
this.targetYInner = distY * this.config.innerStrength * force
} else {
// Outside zone — return to 0
this.targetXEl = 0
this.targetYEl = 0
this.targetXInner = 0
this.targetYInner = 0
}
}
private tick = () => {
this.xEl += (this.targetXEl - this.xEl) * this.LERP
this.yEl += (this.targetYEl - this.yEl) * this.LERP
this.xInner += (this.targetXInner - this.xInner) * this.LERP
this.yInner += (this.targetYInner - this.yInner) * this.LERP
this.el.style.transform = `translate(${this.xEl}px, ${this.yEl}px)`
this.inner.style.transform = `translate(${this.xInner}px, ${this.yInner}px)`
this.rafId = requestAnimationFrame(this.tick)
}
destroy() {
if (this.rafId) cancelAnimationFrame(this.rafId)
window.removeEventListener('mousemove', this.onMouseMove)
window.removeEventListener('resize', this.recalcBounds)
this.el.style.transform = ''
this.inner.style.transform = ''
}
}
HTML Structure
Two layers: outer container and inner span with text. Displacements are different.
<button class="magnetic-btn" data-magnetic>
<span class="magnetic-btn__inner" data-magnetic-inner>
Contact
</span>
</button>
.magnetic-btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 16px 40px;
border-radius: 100px;
background: #1a1a1a;
color: #fff;
border: none;
cursor: none; /* if using custom cursor */
will-change: transform;
transition: background 0.3s ease;
}
.magnetic-btn__inner {
display: block;
will-change: transform;
pointer-events: none;
}
Implementation via GSAP
If the project has GSAP, lerp animation is replaced with gsap.quickTo — smoother, without artifacts on fast movement.
import gsap from 'gsap'
class MagneticButtonGSAP {
private el: HTMLElement
private xSetter: (value: number) => void
private ySetter: (value: number) => void
constructor(el: HTMLElement) {
this.el = el
// quickTo creates optimized setter with spring
this.xSetter = gsap.quickTo(el, 'x', { duration: 0.6, ease: 'power3' })
this.ySetter = gsap.quickTo(el, 'y', { duration: 0.6, ease: 'power3' })
el.addEventListener('mousemove', this.onMove)
el.addEventListener('mouseleave', this.onLeave)
}
private onMove = (e: MouseEvent) => {
const rect = this.el.getBoundingClientRect()
const x = e.clientX - rect.left - rect.width / 2
const y = e.clientY - rect.top - rect.height / 2
this.xSetter(x * 0.35)
this.ySetter(y * 0.35)
}
private onLeave = () => {
this.xSetter(0)
this.ySetter(0)
}
}
React Component
import { useRef, useCallback } from 'react'
import { motion, useMotionValue, useSpring } from 'framer-motion'
interface MagneticProps {
children: React.ReactNode
strength?: number
}
export function Magnetic({ children, strength = 0.4 }: MagneticProps) {
const ref = useRef<HTMLDivElement>(null)
const xRaw = useMotionValue(0)
const yRaw = useMotionValue(0)
const x = useSpring(xRaw, { stiffness: 200, damping: 15 })
const y = useSpring(yRaw, { stiffness: 200, damping: 15 })
const onMove = useCallback((e: React.MouseEvent) => {
if (!ref.current) return
const rect = ref.current.getBoundingClientRect()
const dx = e.clientX - rect.left - rect.width / 2
const dy = e.clientY - rect.top - rect.height / 2
xRaw.set(dx * strength)
yRaw.set(dy * strength)
}, [strength])
const onLeave = useCallback(() => {
xRaw.set(0)
yRaw.set(0)
}, [])
return (
<motion.div
ref={ref}
style={{ x, y, display: 'inline-block' }}
onMouseMove={onMove}
onMouseLeave={onLeave}
>
{children}
</motion.div>
)
}
Features and Limitations
ResizeObserver instead of window resize — when layout changes via CSS (e.g., sticky header), getBoundingClientRect becomes stale. Need ResizeObserver + bounds recalc on scroll.
Multiple initialization — if page loads content via AJAX or SPA navigation, need destroy + reinit logic. Pattern: store Map (element -> instance) and check before creation.
Performance on mobile — touch devices don't need this effect (no hover). Add check matchMedia('(hover: hover)') and skip initialization.
Timeline
Ready implementation with plain JS/TS, no dependencies — 4–6 hours. With GSAP or framer-motion, React component and custom cursor support — 1–2 days.







