Реалізація ефекту Magnetic Button на веб-сайті
Magnetic Button — кнопка, яка притягується до курсора при наближенні. Не сам елемент зсувається в сторону миші — внутрішній вміст (текст, іконка) зміщується сильніше, ніж зовнішній контейнер. Це створює ефект пружності та матеріальності.
Ефект працює на трьох принципах: зона притяжіння ширша за саму кнопку, рух відбувається з уповільненням (spring), при вході курсора — повернення в вихідне положення з пружністю.
Математика притяжіння
Для кожного кадру обчислюється відстань від центру кнопки до курсора. Якщо курсор у зоні притяжіння — обчислюється зміщення, пропорційне відстані та нормалізоване до максимального значення.
interface MagneticConfig {
strength: number // сила притяжіння, 0.3–0.6
innerStrength: number // сила для внутрішнього контенту, 0.6–1.2
radius: number // множник зони притяжіння від розміру кнопки
}
class MagneticButton {
private el: HTMLElement
private inner: HTMLElement
private bounds: DOMRect
private config: MagneticConfig
private rafId: number | null = null
// Поточні зміщення (анімовані)
private xEl = 0
private yEl = 0
private xInner = 0
private yInner = 0
// Цільові зміщення
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
// Відстань у одиницях "напівширини кнопки"
const distance = Math.sqrt(distX ** 2 + distY ** 2)
const threshold = (Math.max(width, height) / 2) * this.config.radius
if (distance < threshold) {
// У зоні притяжіння — обчислюємо зміщення
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 {
// Поза зоною — повернення до 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-структура
Два шари: зовнішній контейнер та внутрішній span з текстом. Зміщення — різні.
<button class="magnetic-btn" data-magnetic>
<span class="magnetic-btn__inner" data-magnetic-inner>
Зв'язатися
</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; /* якщо використовується кастомний курсор */
will-change: transform;
transition: background 0.3s ease;
}
.magnetic-btn__inner {
display: block;
will-change: transform;
pointer-events: none;
}
Реалізація через GSAP
Якщо в проекті є GSAP, lerp-анімація замінюється на gsap.quickTo — більш плавно, без артефактів при швидкому русі.
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 створює оптимізований setter з 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-компонент
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>
)
}
Особливості та обмеження
ResizeObserver замість window resize — при зміні макету через CSS (наприклад, sticky header) getBoundingClientRect застаріває. Потрібен ResizeObserver + пересчет bounds при scroll.
Багаторазова інітіалізація — якщо сторінка завантажує контент через AJAX або SPA-навігацію, потрібна логіка destroy + reinit. Паттерн: зберігати Map (element -> instance) та перевіряти перед створенням.
Продуктивність на мобілці — на touch-пристроях цей ефект не потрібен (немає hover). Додаємо перевірку matchMedia('(hover: hover)') та пропускаємо ініціалізацію.
Терміни
Готова реалізація з plain JS/TS, без залежностей — 4–6 годин. З GSAP або framer-motion, React-компонентом та підтримкою custom cursor — 1–2 дні.







