26 CSS Link Effects22 / 26

CSS + JSMIT licensed

CSS Magnetic Cursor Hover Effect

The awwwards-site staple, in ~30 dependency-free lines: a CTA that leans toward your cursor inside its magnetic field, its label drifting a beat further for parallax depth — then releasing with a genuine spring wobble courtesy of CSS linear() easing (no physics library; the spring curve IS the easing function). Plus an underline dot that tracks your cursor along a text link.

Published Updated

Live Demo
Try it

The code

<section class="cle-22" aria-label="Magnetic hover demo">
  <div class="cle-22__stage">
    <div class="cle-22__var">
      <span class="cle-22__field"><a href="#a" class="cle-22__magnet"><span class="cle-22__t">Let's work together</span></a></span>
      <small class="cle-22__cap">A · magnetic pull + spring release</small>
    </div>
    <div class="cle-22__var">
      <a href="#b" class="cle-22__dotlink">hello@codefronts.com</a>
      <small class="cle-22__cap">B · dot shadows the cursor</small>
    </div>
  </div>
</section>
.cle-22,
.cle-22 *,
.cle-22 *::before,
.cle-22 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.cle-22 {
  width: 100%;
  min-height: 100vh;
  --bg: oklch(0.18 0.02 270);
  --cream: oklch(0.95 0.015 90);
  --brand: oklch(0.75 0.16 60);
  font-family: 'Segoe UI',system-ui,sans-serif;
}

.cle-22__stage {
  width: 100%;
  height: 100vh;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 40px 70px;
  background: radial-gradient(100% 130% at 50% 0%,oklch(0.24 0.025 275),oklch(0.16 0.02 265) 65%);
  padding: 36px;
}

.cle-22__var {
  display: grid;
  gap: 6px;
  justify-items: center;
}

.cle-22__cap {
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: oklch(0.58 0.03 270);
}

.cle-22__field {
  display: inline-block;
  padding: 34px 40px;
}

.cle-22__magnet {
  display: inline-block;
  padding: 19px 36px;
  border-radius: 999px;
  background: var(--cream);
  color: oklch(0.2 0.02 270);
  text-decoration: none;
  font-size: 16px;
  font-weight: 700;
  letter-spacing: .01em;
  translate: var(--tx,0px) var(--ty,0px);
  transition: translate .16s ease-out;
  box-shadow: 0 18px 44px -18px oklch(0.75 0.16 60 / .35);
}

.cle-22__magnet .cle-22__t {
  display: inline-block;
  translate: var(--lx,0px) var(--ly,0px);
  transition: translate .16s ease-out;
}

.cle-22__magnet.cle-22__spring,
.cle-22__magnet.cle-22__spring .cle-22__t {
  transition: translate .6s cubic-bezier(.2,1.6,.35,1);
  transition: translate .6s linear(0,0.218 2.1%,0.862 6.5%,1.114 8.6%,1.245 10.7%,1.279 12.4%,1.252 14.1%,1.152 16.7%,0.998 20.2%,0.917 22.9%,0.891 25.7%,0.906 28.8%,0.998 34.3%,1.036 38.1%,1.014 44.3%,0.997 51.4%,1.001 61.5%,1);
}

.cle-22__magnet:hover {
  background: oklch(0.98 0.01 90);
}

.cle-22__magnet:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: 4px;
}

.cle-22__dotlink {
  position: relative;
  color: var(--cream);
  text-decoration: none;
  font-size: 20px;
  font-weight: 600;
  letter-spacing: .01em;
  padding-bottom: 12px;
  transition: color .3s;
}

.cle-22__dotlink:hover,
.cle-22__dotlink:focus-visible {
  color: var(--brand);
}

.cle-22__dotlink:focus-visible {
  outline: 2px solid var(--brand);
  outline-offset: 5px;
  border-radius: 3px;
}

.cle-22__dotlink::after {
  content: "";
  position: absolute;
  bottom: 0;
  left: var(--mx,50%);
  translate: -50% 0;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--brand);
  opacity: 0;
  scale: .4;
  transition: left .15s ease-out,opacity .25s,scale .3s cubic-bezier(.3,1.4,.4,1);
}

.cle-22__dotlink:hover::after,
.cle-22__dotlink:focus-visible::after {
  opacity: 1;
  scale: 1;
}

@media (prefers-reduced-motion: reduce) {
  .cle-22 * {
    transition-duration: .01s !important;
  }
}
(() => {
  if (matchMedia('(hover: none), (prefers-reduced-motion: reduce)').matches) return;
  document.querySelectorAll('.cle-22__field').forEach((field) => {
    const link = field.querySelector('.cle-22__magnet');
    const label = link.querySelector('.cle-22__t');
    let raf = 0, ev = null;
    const apply = () => {
      raf = 0;
      const r = link.getBoundingClientRect();
      const dx = ev.clientX - (r.left + r.width / 2);
      const dy = ev.clientY - (r.top + r.height / 2);
      link.style.setProperty('--tx', (dx * 0.34).toFixed(1) + 'px');
      link.style.setProperty('--ty', (dy * 0.34).toFixed(1) + 'px');
      label.style.setProperty('--lx', (dx * 0.18).toFixed(1) + 'px');
      label.style.setProperty('--ly', (dy * 0.18).toFixed(1) + 'px');
    };
    field.addEventListener('pointerenter', () => link.classList.remove('cle-22__spring'));
    field.addEventListener('pointermove', (e) => { ev = e; if (!raf) raf = requestAnimationFrame(apply); });
    field.addEventListener('pointerleave', () => {
      link.classList.add('cle-22__spring');
      ['--tx','--ty'].forEach((p) => link.style.setProperty(p, '0px'));
      ['--lx','--ly'].forEach((p) => label.style.setProperty(p, '0px'));
    });
  });
  document.querySelectorAll('.cle-22__dotlink').forEach((a) => {
    a.addEventListener('pointermove', (e) => {
      const r = a.getBoundingClientRect();
      const x = Math.max(4, Math.min(r.width - 4, e.clientX - r.left));
      a.style.setProperty('--mx', x.toFixed(0) + 'px');
    });
    a.addEventListener('pointerleave', () => a.style.setProperty('--mx', '50%'));
  });
})();
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Link Effect from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: CSS Magnetic Cursor Hover Effect
Source: https://codefronts.com/motion/css-link-effects/magnetic-pull/

The awwwards-site staple, in ~30 dependency-free lines: a CTA that leans toward your cursor inside its magnetic field, its label drifting a beat further for parallax depth — then releasing with a genuine spring wobble courtesy of CSS linear() easing (no physics library; the spring curve IS the easing function). Plus an underline dot that tracks your cursor along a text link.
## HTML
```html
<section class="cle-22" aria-label="Magnetic hover demo">
  <div class="cle-22__stage">
    <div class="cle-22__var">
      <span class="cle-22__field"><a href="#a" class="cle-22__magnet"><span class="cle-22__t">Let's work together</span></a></span>
      <small class="cle-22__cap">A · magnetic pull + spring release</small>
    </div>
    <div class="cle-22__var">
      <a href="#b" class="cle-22__dotlink">hello@codefronts.com</a>
      <small class="cle-22__cap">B · dot shadows the cursor</small>
    </div>
  </div>
</section>
```
## CSS
```css
.cle-22,
.cle-22 *,
.cle-22 *::before,
.cle-22 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.cle-22 {
  width: 100%;
  min-height: 100vh;
  --bg: oklch(0.18 0.02 270);
  --cream: oklch(0.95 0.015 90);
  --brand: oklch(0.75 0.16 60);
  font-family: 'Segoe UI',system-ui,sans-serif;
}

.cle-22__stage {
  width: 100%;
  height: 100vh;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 40px 70px;
  background: radial-gradient(100% 130% at 50% 0%,oklch(0.24 0.025 275),oklch(0.16 0.02 265) 65%);
  padding: 36px;
}

.cle-22__var {
  display: grid;
  gap: 6px;
  justify-items: center;
}

.cle-22__cap {
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: oklch(0.58 0.03 270);
}

.cle-22__field {
  display: inline-block;
  padding: 34px 40px;
}

.cle-22__magnet {
  display: inline-block;
  padding: 19px 36px;
  border-radius: 999px;
  background: var(--cream);
  color: oklch(0.2 0.02 270);
  text-decoration: none;
  font-size: 16px;
  font-weight: 700;
  letter-spacing: .01em;
  translate: var(--tx,0px) var(--ty,0px);
  transition: translate .16s ease-out;
  box-shadow: 0 18px 44px -18px oklch(0.75 0.16 60 / .35);
}

.cle-22__magnet .cle-22__t {
  display: inline-block;
  translate: var(--lx,0px) var(--ly,0px);
  transition: translate .16s ease-out;
}

.cle-22__magnet.cle-22__spring,
.cle-22__magnet.cle-22__spring .cle-22__t {
  transition: translate .6s cubic-bezier(.2,1.6,.35,1);
  transition: translate .6s linear(0,0.218 2.1%,0.862 6.5%,1.114 8.6%,1.245 10.7%,1.279 12.4%,1.252 14.1%,1.152 16.7%,0.998 20.2%,0.917 22.9%,0.891 25.7%,0.906 28.8%,0.998 34.3%,1.036 38.1%,1.014 44.3%,0.997 51.4%,1.001 61.5%,1);
}

.cle-22__magnet:hover {
  background: oklch(0.98 0.01 90);
}

.cle-22__magnet:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: 4px;
}

.cle-22__dotlink {
  position: relative;
  color: var(--cream);
  text-decoration: none;
  font-size: 20px;
  font-weight: 600;
  letter-spacing: .01em;
  padding-bottom: 12px;
  transition: color .3s;
}

.cle-22__dotlink:hover,
.cle-22__dotlink:focus-visible {
  color: var(--brand);
}

.cle-22__dotlink:focus-visible {
  outline: 2px solid var(--brand);
  outline-offset: 5px;
  border-radius: 3px;
}

.cle-22__dotlink::after {
  content: "";
  position: absolute;
  bottom: 0;
  left: var(--mx,50%);
  translate: -50% 0;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--brand);
  opacity: 0;
  scale: .4;
  transition: left .15s ease-out,opacity .25s,scale .3s cubic-bezier(.3,1.4,.4,1);
}

.cle-22__dotlink:hover::after,
.cle-22__dotlink:focus-visible::after {
  opacity: 1;
  scale: 1;
}

@media (prefers-reduced-motion: reduce) {
  .cle-22 * {
    transition-duration: .01s !important;
  }
}
```

## JavaScript
```js
(() => {
  if (matchMedia('(hover: none), (prefers-reduced-motion: reduce)').matches) return;
  document.querySelectorAll('.cle-22__field').forEach((field) => {
    const link = field.querySelector('.cle-22__magnet');
    const label = link.querySelector('.cle-22__t');
    let raf = 0, ev = null;
    const apply = () => {
      raf = 0;
      const r = link.getBoundingClientRect();
      const dx = ev.clientX - (r.left + r.width / 2);
      const dy = ev.clientY - (r.top + r.height / 2);
      link.style.setProperty('--tx', (dx * 0.34).toFixed(1) + 'px');
      link.style.setProperty('--ty', (dy * 0.34).toFixed(1) + 'px');
      label.style.setProperty('--lx', (dx * 0.18).toFixed(1) + 'px');
      label.style.setProperty('--ly', (dy * 0.18).toFixed(1) + 'px');
    };
    field.addEventListener('pointerenter', () => link.classList.remove('cle-22__spring'));
    field.addEventListener('pointermove', (e) => { ev = e; if (!raf) raf = requestAnimationFrame(apply); });
    field.addEventListener('pointerleave', () => {
      link.classList.add('cle-22__spring');
      ['--tx','--ty'].forEach((p) => link.style.setProperty(p, '0px'));
      ['--lx','--ly'].forEach((p) => label.style.setProperty(p, '0px'));
    });
  });
  document.querySelectorAll('.cle-22__dotlink').forEach((a) => {
    a.addEventListener('pointermove', (e) => {
      const r = a.getBoundingClientRect();
      const x = Math.max(4, Math.min(r.width - 4, e.clientX - r.left));
      a.style.setProperty('--mx', x.toFixed(0) + 'px');
    });
    a.addEventListener('pointerleave', () => a.style.setProperty('--mx', '50%'));
  });
})();
```

How this works

An invisible padded wrapper defines the magnetic field. On pointermove, JS measures the pointer's offset from the link's center and writes a fraction of it to two custom properties — throttled to one write per frame with requestAnimationFrame:

link.style.setProperty('--tx', dx * 0.34 + 'px');
/* CSS side */
a{ translate: var(--tx,0px) var(--ty,0px); transition: translate .16s ease-out; }

The short ease-out transition is the secret to the buttery feel — JS supplies raw targets, CSS smooths between them, no lerp loop needed. The label repeats the trick at a smaller multiplier (0.18 vs 0.34), so it lags behind the pill — instant depth.

On pointerleave, the vars reset to 0 under a .is-spring class whose transition swaps to a hand-tuned linear() spring curve — the values overshoot past center and oscillate down, exactly like a damped spring, because the easing function literally encodes one. The whole effect is gated: matchMedia('(hover:none), (prefers-reduced-motion:reduce)') bails before any listener attaches.

Make it yours

  • Pull strength: the 0.34/0.18 multipliers (pill/label). Past ~0.5 the button escapes the cursor and the illusion dies.
  • Field size: the wrapper's padding — bigger apron, earlier attraction.
  • Release character: swap the linear() curve; fewer oscillation points = tighter damping. A cubic-bezier(.2,1.6,.35,1) is the cheap two-keyword fallback (already declared first).
  • The dot tracker's smoothing is its .15s left transition — raise for laggier, liquid feel.

Gotchas — read before shipping

  • Gate it. Touch devices fire pointer events on tap and the button lurches — the matchMedia guard isn't optional. Same for reduced-motion users; a magnetic element is pure vestibular noise.
  • Write transforms via custom properties (as here) rather than style.transform strings, so the CSS transition — not JS — owns the interpolation.
  • Keep the link's focus ring on the *link*, not the wrapper; the wrapper is presentational and must stay pointer-transparent to clicks (it isn't a button).

Browser support

ChromeSafariFirefoxEdge
113+17.2+112+113+

linear() easing: Chrome 113, Firefox 112, Safari 17.2 — older engines use the declared cubic-bezier fallback and lose only the wobble. Everything else (pointer events, custom properties, individual translate) is Baseline.

Search CodeFronts

Loading…