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.

gsaphovermagneticbuttonmousemoveanimation

Setup — External Scripts

Setup: External Scripts
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>

Code

index.html
html
<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>
styles.css
css
.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);
}
script.js
javascript
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.