Реализация 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 — при изменении layout через CSS (например, sticky header) getBoundingClientRect устаревает. Нужен ResizeObserver + пересчёт bounds при scroll.
Многократная инициализация — если страница подгружает контент через AJAX или SPA-навигацию, нужна логика destroy + reinit. Паттерн: хранить Map (element -> instance) и проверять перед созданием.
Performance на мобилке — на тач-устройствах эффект не нужен (нет hover). Добавляем проверку matchMedia('(hover: hover)') и пропускаем инициализацию.
Сроки
Готовая реализация с plain JS/TS, без зависимостей — 4–6 часов. С GSAP или framer-motion, React-компонентом и поддержкой custom cursor — 1–2 дня.







