Magnetic Hover Effect
On mousemove, calculates the cursor offset from the element's center and applies a proportional GSAP x/y translation to both the outer element and an optional inner target at independent strengths. On mouseleave both elements spring back with an elastic ease. Disabled below 992px.
Setup — External Scripts
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>Code
<div class="btn-magnetic">
<a href="#" class="btn-magnetic__click" data-magnetic-strength="50" data-magnetic-strength-inner="25">
<div class="btn-magnetic__fill"></div>
<div data-magnetic-inner-target="" class="btn-magnetic__content">
<div class="btn-magnetic__text">
<p class="btn-magnetic__text-p">Magnetic Effect</p>
<p class="btn-magnetic__text-p is--duplicate">Magnetic Effect</p>
</div>
</div>
</a>
</div>.btn-magnetic {
font-size: 1em;
position: relative;
}
.btn-magnetic__click {
cursor: pointer;
border-radius: 4em;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
display: flex;
position: relative;
overflow: hidden;
text-decoration: none;
}
.btn-magnetic__fill {
background-color: #6448b2;
width: 100%;
height: 100%;
position: absolute;
}
.btn-magnetic__content {
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
padding: .75em 2em;
display: flex;
position: relative;
}
.btn-magnetic__text {
position: relative;
overflow: hidden;
}
.btn-magnetic__text-p {
color: #ede7ff;
text-align: center;
margin-bottom: 0;
font-size: 1em;
font-weight: 500;
line-height: 1.5;
position: relative;
}
.btn-magnetic__text-p.is--duplicate {
position: absolute;
top: 100%;
}
/* Hover */
.btn-magnetic__click .btn-magnetic__text-p {
transition: all 0.6s cubic-bezier(0.625, 0.05, 0, 1);
transform: translateY(0%) rotate(0.001deg);
}
.btn-magnetic__click:hover .btn-magnetic__text-p {
transform: translateY(-100%) rotate(0.001deg);
}function initMagneticEffect() {
const magnets = document.querySelectorAll('[data-magnetic-strength]');
if (window.innerWidth <= 991) return;
// Helper to kill tweens and reset an element.
const resetEl = (el, immediate) => {
if (!el) return;
gsap.killTweensOf(el);
(immediate ? gsap.set : gsap.to)(el, {
x: "0em",
y: "0em",
rotate: "0deg",
clearProps: "all",
...(!immediate && { ease: "elastic.out(1, 0.3)", duration: 1.6 })
});
};
const resetOnEnter = e => {
const m = e.currentTarget;
resetEl(m, true);
resetEl(m.querySelector('[data-magnetic-inner-target]'), true);
};
const moveMagnet = e => {
const m = e.currentTarget,
b = m.getBoundingClientRect(),
strength = parseFloat(m.getAttribute('data-magnetic-strength')) || 25,
inner = m.querySelector('[data-magnetic-inner-target]'),
innerStrength = parseFloat(m.getAttribute('data-magnetic-strength-inner')) || strength,
offsetX = ((e.clientX - b.left) / m.offsetWidth - 0.5) * (strength / 16),
offsetY = ((e.clientY - b.top) / m.offsetHeight - 0.5) * (strength / 16);
gsap.to(m, { x: offsetX + "em", y: offsetY + "em", rotate: "0.001deg", ease: "power4.out", duration: 1.6 });
if (inner) {
const innerOffsetX = ((e.clientX - b.left) / m.offsetWidth - 0.5) * (innerStrength / 16),
innerOffsetY = ((e.clientY - b.top) / m.offsetHeight - 0.5) * (innerStrength / 16);
gsap.to(inner, { x: innerOffsetX + "em", y: innerOffsetY + "em", rotate: "0.001deg", ease: "power4.out", duration: 2 });
}
};
const resetMagnet = e => {
const m = e.currentTarget,
inner = m.querySelector('[data-magnetic-inner-target]');
gsap.to(m, { x: "0em", y: "0em", ease: "elastic.out(1, 0.3)", duration: 1.6, clearProps: "all" });
if (inner) {
gsap.to(inner, { x: "0em", y: "0em", ease: "elastic.out(1, 0.3)", duration: 2, clearProps: "all" });
}
};
magnets.forEach(m => {
m.addEventListener('mouseenter', resetOnEnter);
m.addEventListener('mousemove', moveMagnet);
m.addEventListener('mouseleave', resetMagnet);
});
}
// Initialize Magnetic Effect
document.addEventListener('DOMContentLoaded', () => {
initMagneticEffect();
});Guide
How It Works
On mousemove, the cursor position is measured relative to the element's bounding box. The offset from center (0.5) is multiplied by the strength value divided by 16 to produce an em-based translation, keeping the movement proportional to font-size.
data-magnetic-strength
Add data-magnetic-strength on the trigger element to set how far it pulls toward the cursor. Higher values mean more movement. Example: data-magnetic-strength="50".
Inner Target
Add data-magnetic-inner-target to a child element and data-magnetic-strength-inner on the trigger to animate the inner element at a different (usually smaller) strength independently. This creates a parallax-within-parallax feel.
Snap Back
On mouseleave both elements animate back to their origin with elastic.out(1, 0.3), giving a springy return. On mouseenter, positions are instantly reset via gsap.set to prevent a position jump if the cursor re-enters before the spring finishes.
Responsive
The effect is disabled entirely on viewports ≤ 991px, where touch is expected and a magnetic pull would feel wrong.
Usage Beyond Buttons
Any interactive element can use this effect — icons, images, nav links, cards. Just add data-magnetic-strength to the element and optionally data-magnetic-inner-target / data-magnetic-strength-inner for a layered effect.