---
name: magnetic-hover-effect
description: Use when you want "Premium, interactive, modern" - Pulls buttons or links toward the cursor for a responsive magnetic feel.
---

# Magnetic Hover Effect

> **Category:** Motion / Interaction  -  **Personality:** Premium, interactive, modern
>
> **Best use:** CTAs, navigation, portfolio links
>
> **Ratings:** Professional 4/5 - Casual 4/5 - Exotic 4/5 - Visual impact 4/5 - Difficulty 4/5 - Performance cost 3/5

## What it is

A magnetic element appears to be "attracted" to the cursor: while the pointer is near it, the element slides a fraction of the way toward the cursor, and the moment the pointer leaves it springs back to rest. Wrapping the element in a slightly padded hit-zone lets the pull begin *before* the cursor reaches the element, and translating the inner label a touch further than the element itself adds a subtle parallax that makes the motion feel alive. It is a pointer-only flourish — pure `transform`, no layout — used to make primary CTAs, nav links and portfolio tiles feel responsive and premium.

## Dependencies / CDN

None - vanilla JS + CSS. The demo only loads display/body/mono fonts (optional):

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@600;700;800&family=Hanken+Grotesk:wght@400;500;600&family=Spline+Sans+Mono:wght@400;500&display=swap" rel="stylesheet">
```

## HTML

Every magnetic thing uses the same three-part shape: a `.mh-magnet` **wrapper** (catches the pointer, its padding is the magnetic hit-zone), a `.mh-target` that JS translates, and a `.mh-label` it translates a little further. Tunables live in `data-*`.

```html
<!-- primary CTA: follows at 0.42, label parallax 0.22, lifts to scale 1.03 while pulled -->
<span class="mh-magnet mh-cta" data-strength="0.42" data-label="0.22" data-scale="1.03">
  <a class="mh-target" href="#">
    <span class="mh-label">Start a project</span><span class="mh-arrow" aria-hidden="true">&rarr;</span>
  </a>
</span>

<!-- a nav link: snappier follow, the text leads -->
<span class="mh-magnet" data-strength="0.5" data-label="0.35">
  <a class="mh-target mh-link" href="#"><span class="mh-label">Work</span></a>
</span>

<!-- a round social/icon button -->
<span class="mh-magnet mh-icon" data-strength="0.55" data-label="0.3" data-scale="1.12">
  <a class="mh-target" href="#" aria-label="Ferro on Instagram">
    <span class="mh-label"><svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><rect x="3.2" y="3.2" width="17.6" height="17.6" rx="5"/><circle cx="12" cy="12" r="4"/><circle cx="17.2" cy="6.8" r="1.15" fill="currentColor" stroke="none"/></svg></span>
  </a>
</span>
```

## CSS

The only lines that *make* the effect are the transitions on `.mh-target` / `.mh-label` and the springy snap-back on `:not(.is-pulled)`. Everything else is dressing for the demo's palette.

```css
/* 1) THE MECHANICS — JS writes transform; CSS owns the easing */
.mh-magnet{display:inline-flex;align-items:center;justify-content:center}      /* padding = hit-zone */
.mh-target{position:relative;display:inline-flex;align-items:center;text-decoration:none;color:inherit;
  transition:transform .3s cubic-bezier(.2,.8,.2,1),box-shadow .35s ease,border-color .3s,background .3s,color .25s}
.mh-label{display:inline-flex;align-items:center;transition:transform .3s cubic-bezier(.2,.8,.2,1)}
/* the springy snap-back happens only when NOT being pulled (note the >1 overshoot in the curve) */
.mh-magnet:not(.is-pulled) .mh-target{transition:transform .62s cubic-bezier(.16,1.3,.42,1),box-shadow .4s,border-color .3s,background .3s,color .25s}
.mh-magnet:not(.is-pulled) .mh-label{transition:transform .66s cubic-bezier(.16,1.34,.42,1)}
.mh-target:focus-visible{outline:2px solid var(--mh-acc);outline-offset:4px}

/* 2) DRESSING — one button skinned, plus the "pulled" affordances */
.mh-cta{padding:17px}                                                          /* hit-zone around the CTA */
.mh-cta .mh-target{gap:13px;background:var(--mh-acc);color:var(--mh-acc-ink);font-weight:700;font-size:17px;
  padding:19px 32px;border-radius:999px;box-shadow:0 14px 40px -14px var(--mh-glow)}
.mh-cta.is-pulled .mh-target{box-shadow:0 22px 60px -12px var(--mh-glow),0 0 0 5px rgba(205,251,77,.16)}
.mh-arrow{font-weight:600;font-size:19px;line-height:1;transition:transform .35s cubic-bezier(.2,.8,.2,1)}
.mh-cta.is-pulled .mh-arrow{transform:translateX(5px)}                         /* arrow nudges on pull */

/* demo palette (any theme works) */
.mh-stage{--mh-acc:#cdfb4d;--mh-acc-ink:#0c0e07;--mh-glow:rgba(205,251,77,.5)}

@media (prefers-reduced-motion: reduce){ /* JS also bails; this kills the ambient bits */ }
```

## JavaScript

Vanilla. For each `.mh-magnet`: on `pointermove`, translate `.mh-target` by `strength ×` the cursor-to-center offset (and the label a little more); on `pointerleave` clear the inline transform so the `:not(.is-pulled)` spring eases it home. Gate the whole thing on a fine pointer + reduced-motion.

```js
(function(){
  var stage = document.querySelector('.mh-stage');     // scope to your container
  if(!stage) return;
  var reduce = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches;
  var fine   = !window.matchMedia || matchMedia('(hover: hover) and (pointer: fine)').matches;
  if(!(fine && !reduce)) return;                        // pointer-only enhancement

  Array.prototype.forEach.call(stage.querySelectorAll('.mh-magnet'), function(m){
    var target = m.querySelector('.mh-target') || m;
    var label  = m.querySelector('.mh-label');
    var s  = parseFloat(m.getAttribute('data-strength')) || 0.4; // how far the element follows
    var ls = parseFloat(m.getAttribute('data-label'))    || 0;   // extra parallax for the label
    var sc = parseFloat(m.getAttribute('data-scale'))    || 1;   // scale while pulled

    function move(e){
      var r = target.getBoundingClientRect();
      var x = (e.clientX - (r.left + r.width/2)) * s;
      var y = (e.clientY - (r.top + r.height/2)) * s;
      m.classList.add('is-pulled');
      target.style.transform = 'translate('+x.toFixed(2)+'px,'+y.toFixed(2)+'px) scale('+sc+')';
      if(label) label.style.transform = 'translate('+(x*ls).toFixed(2)+'px,'+(y*ls).toFixed(2)+'px)';
    }
    function reset(){
      m.classList.remove('is-pulled');
      target.style.transform = '';
      if(label) label.style.transform = '';
    }
    m.addEventListener('pointermove', move);
    m.addEventListener('pointerleave', reset);
    m.addEventListener('pointercancel', reset);
    m.addEventListener('blur', reset, true);            // clear if focus leaves mid-pull
  });
})();
```

*(The live demo adds an optional flourish — a cursor-following accent glow and a "field strength" readout — driven by a `pointermove` listener on the stage with a `requestAnimationFrame` write. Pure dressing; not part of the effect.)*

## How it works

- **Offset from center × strength.** `pointermove` gives the cursor position; subtract the element's center (`rect.left + width/2`) and multiply by `strength` (≈0.3–0.5). That vector is written straight into `transform: translate()`, so the element leans toward the cursor.
- **The label leads.** Translating `.mh-label` by an *extra* `strength × data-label` makes the text drift slightly more than its button — a parallax that reads as depth and sells the "magnetism".
- **Two transitions, toggled by a class.** While pulling, `.is-pulled` is on → a short `.3s` ease (the element tracks smoothly). On `pointerleave` the class comes off → the `:not(.is-pulled)` rule swaps in a longer `cubic-bezier(.16,1.3,.42,1)` whose **>1 control point overshoots**, giving the springy snap-back.
- **Self-damping.** Measuring the *current* (already-transformed) rect each move means the resting offset settles at `D·s/(1+s)` instead of running away — smooth and stable with no clamping.
- **Hit-zone from padding.** Listening on the padded `.mh-magnet` wrapper (not the button) lets the pull start a few px before the cursor touches the element — the hallmark of a premium magnetic button.

## Customization

- **Strength / parallax / lift:** per-element via `data-strength`, `data-label`, `data-scale`. Big CTA ≈ 0.4 strength + small scale; icons ≈ 0.55 + bigger scale; nav links higher strength, label-led, no scale.
- **Springiness:** push the leave curve's second value up (`cubic-bezier(.16,1.5,.4,1)`) for more bounce, down toward `1` for a calm settle; shorten/lengthen the `.62s` for snap vs. float.
- **Pull range:** widen the wrapper `padding` to start the attraction from further away; tighten it for a "only when nearly there" feel.
- **Affordances on pull:** key extra cues off `.is-pulled` — glow/`box-shadow`, border color, an arrow nudge, color shift — so the state change is felt, not just the motion.

## Accessibility & performance

- **Progressive enhancement.** Targets are real `<a>` / `<button>` elements; the JS only adds movement for `(hover: hover) and (pointer: fine)` and bails entirely under `prefers-reduced-motion`. Keyboard users get normal focus + a `:focus-visible` ring; nothing depends on the motion.
- **Cheap to animate.** Only `transform` (and `box-shadow`/`opacity`) change — GPU-composited, no reflow. `pointermove` just assigns a style string; the ambient glow batches its write in `requestAnimationFrame`.
- **Keep it sparing.** A handful of magnets per view is plenty; magnetizing dozens of elements multiplies listeners and `getBoundingClientRect` calls. Use `will-change:transform` only on the one or two heaviest elements.

## Gotchas

- **JS owns the `transform`.** Don't also set `transform` in CSS on the same element — the inline style wins and they fight. Fold the scale into the JS string (as here), or apply CSS-driven transforms to a *child*.
- **You must clear it on leave.** If `pointerleave` (and `pointercancel`) don't reset the inline transform, the element sticks where the cursor left it. The springy easing lives on `:not(.is-pulled)`, so removing the class is what triggers the snap-back.
- **Live rect = damped pull.** Reading the transformed rect each move makes the resting offset smaller than `strength` implies (self-stabilizing). If you want the exact strength, cache the rect on `pointerenter` instead.
- **Touch & coarse pointers.** Skip the effect there (the demo's `(pointer: fine)` gate) — `pointermove` during a touch-drag makes buttons jump under the finger.
- **Overlapping hit-zones** between adjacent magnets are fine: leaving one fires its reset as you enter the next, so both behave — just don't make the padding so large that unrelated elements pull at once.
