CSS + JS Buttons & forms #controls #pointer #button

Magnetic button in CSS + JavaScript

Inside its field the button shifts and leans toward the pointer while its label floats higher, so the two slide apart. JS only reports the pointer position; release it and a springy transition takes it home.

  • 82 lines of CSS
  • 4 lines of HTML
  • 24 lines of JS
  • No dependencies
  • MIT licensed

How it works

  1. JS only ever reports one thing: the pointer’s position inside the field, remapped to two numbers --mx / --my from -1 to 1. Every visible motion is CSS reading those two custom properties.
  2. translate3d(...) plus two rotates built from the same --mx/--my shift the button toward the pointer and lean it the way it is being pulled, all in one transform.
  3. The label inside the button sits at a deeper translateZ than the button’s own lift — same tilt, more travel — so it visibly slides apart from its cap: parallax from one shared tilt.
  4. While the pointer is inside the field (.is-live) the transition is quick and linear so it tracks directly; on release a long cubic-bezier with overshoot takes over, reading as a spring pulling the button home.

Key techniques

  • pointer → --mx / --my
  • translate3d + rotate from the same two numbers
  • label at translateZ for parallax
  • overshoot easing as a spring

Copy-paste code

HTML 4 lines

<div class="magnet">
  <i class="glow"></i>
  <button type="button" class="btn"><span>Pull me</span></button>
</div>

CSS 82 lines

.magnet {
  position: relative;
  display: grid;
  place-items: center;
  width: 220px;
  height: 160px;
  border: 1px dashed #262b4a;
  border-radius: 18px;
  perspective: 800px;
  transform-style: preserve-3d;
  touch-action: none; /* let a finger drag inside the field instead of scrolling the page */
}

/* the glow lies on the floor of the field, behind the button, and runs further than it */
.glow {
  position: absolute;
  top: calc(50% - 60px);
  left: calc(50% - 60px);
  width: 120px;
  height: 120px;
  border-radius: 50%;
  background: radial-gradient(circle, rgb(46 230 214 / 0.55), transparent 68%);
  opacity: 0;
  pointer-events: none;
  transform: translate3d(calc(var(--mx, 0) * 70px), calc(var(--my, 0) * 44px), 0);
  transition: transform 0.7s cubic-bezier(0.3, 1.6, 0.5, 1), opacity 0.4s;
}

/* the button shifts toward the pointer and leans the way it is pulled; it floats 34px above
   the field so its far, tipped-back edge still stays in front of the field's own plane */
.btn {
  position: relative;
  width: 138px;
  height: 50px;
  padding: 0;
  border: 1px solid #a996ff;
  border-radius: 14px;
  background: linear-gradient(140deg, #8b6cff, #bf5ed3);
  box-shadow: 0 16px 26px -14px rgb(139 108 255 / 0.9);
  color: #fff;
  font: inherit;
  font-size: 15px;
  font-weight: 800;
  cursor: pointer;
  transform-style: preserve-3d;
  transform:
    translate3d(calc(var(--mx, 0) * 24px), calc(var(--my, 0) * 16px), 34px)
    rotateY(calc(var(--mx, 0) * 20deg))
    rotateX(calc(var(--my, 0) * -18deg));
  /* the way home: slow, with a big overshoot = a spring */
  transition: transform 0.7s cubic-bezier(0.3, 1.9, 0.5, 1);
}

.btn:focus-visible {
  outline: 2px solid #2ee6d6;
  outline-offset: 4px;
}

/* the label floats above the button's face; same tilt, more depth = it slides further than
   the face under it: parallax */
.btn span {
  display: block;
  pointer-events: none;
  text-shadow: 0 6px 10px rgb(0 0 0 / 0.35);
  transform: translateZ(30px);
  transition: transform 0.2s;
}

.btn:active span {
  transform: translateZ(8px); /* pressed flat */
}

/* while the pointer is inside, follow it closely; the springy transitions above are the release */
.magnet.is-live .btn,
.magnet.is-live .glow {
  transition-duration: 0.14s, 0.4s;
  transition-timing-function: ease-out;
}

.magnet.is-live .glow {
  opacity: 1;
}

JS 24 lines

var field = document.querySelector('.magnet');

function clamp(v, min, max) {
  return Math.min(max, Math.max(min, v));
}

function move(e) {
  // a ratio of the on-screen box, so any CSS scale on the page cancels out
  var r = field.getBoundingClientRect();
  field.style.setProperty('--mx', clamp(((e.clientX - r.left) / r.width) * 2 - 1, -1, 1).toFixed(3));
  field.style.setProperty('--my', clamp(((e.clientY - r.top) / r.height) * 2 - 1, -1, 1).toFixed(3));
  field.classList.add('is-live');
}

function release() {
  field.classList.remove('is-live');
  field.style.removeProperty('--mx');
  field.style.removeProperty('--my');
}

field.addEventListener('pointermove', move);
field.addEventListener('pointerdown', move);
field.addEventListener('pointerleave', release);
field.addEventListener('pointercancel', release);

Sass source 84 lines

// JS writes --mx / --my (-1 ... 1, pointer position inside the field) and toggles .is-live.
// Everything that moves is CSS reading those two numbers.
.d-magnet {
  position: relative;
  display: grid;
  place-items: center;
  width: 220px;
  height: 160px;
  border: 1px dashed var(--border);
  border-radius: 18px;
  transform-style: preserve-3d;
  touch-action: none; // let a finger drag inside the field instead of scrolling the page

  // The glow lies on the floor of the field, behind the button, and runs further than it.
  &__glow {
    position: absolute;
    top: calc(50% - 60px);
    left: calc(50% - 60px);
    width: 120px;
    height: 120px;
    border-radius: 50%;
    background: radial-gradient(circle, color-mix(in srgb, var(--accent-2) 55%, transparent), transparent 68%);
    opacity: 0;
    pointer-events: none;
    transform: translate3d(calc(var(--mx, 0) * 70px), calc(var(--my, 0) * 44px), 0);
    transition:
      transform 0.7s cubic-bezier(0.3, 1.6, 0.5, 1),
      opacity 0.4s;
  }

  // The button shifts toward the pointer and leans the way it is pulled. It floats 34px above
  // the field so that even its far edge, tipped back, stays in front of the field's own plane;
  // otherwise that plane would swallow clicks on the tipped-back half.
  &__btn {
    position: relative;
    width: 138px;
    height: 50px;
    padding: 0;
    border: 1px solid color-mix(in srgb, var(--accent) 80%, #fff);
    border-radius: 14px;
    background: linear-gradient(140deg, var(--accent), color-mix(in srgb, var(--accent) 55%, var(--hot)));
    box-shadow: 0 16px 26px -14px color-mix(in srgb, var(--accent) 90%, transparent);
    color: #fff;
    font: inherit;
    font-size: 15px;
    font-weight: 800;
    cursor: pointer;
    transform-style: preserve-3d;
    transform: translate3d(calc(var(--mx, 0) * 24px), calc(var(--my, 0) * 16px), 34px) rotateY(calc(var(--mx, 0) * 20deg))
      rotateX(calc(var(--my, 0) * -18deg));
    // The way home: slow, with a big overshoot = a spring.
    transition: transform 0.7s cubic-bezier(0.3, 1.9, 0.5, 1);

    &:focus-visible {
      outline: 2px solid var(--accent-2);
      outline-offset: 4px;
    }

    // The label floats above the button's face. Same tilt, more depth = it slides further than
    // the face under it: parallax.
    span {
      display: block;
      pointer-events: none;
      text-shadow: 0 6px 10px rgb(0 0 0 / 0.35);
      transform: translateZ(30px);
      transition: transform 0.2s;
    }

    &:active span {
      transform: translateZ(8px); // pressed flat
    }
  }

  // While the pointer is inside follow it closely; the springy transitions above are the release.
  &.is-live &__btn,
  &.is-live &__glow {
    transition-duration: 0.14s, 0.4s;
    transition-timing-function: ease-out;
  }

  &.is-live &__glow {
    opacity: 1;
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.