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.

  • 94 lines of CSS
  • 4 lines of HTML
  • 24 lines of JS
  • No dependencies
  • Snippet under MIT
GitHub

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 is lifted another 30 units off the button’s face with translateZ. It shares the tilt but stands further from the pivot, so it travels further and visibly slides apart from its cap: parallax from one shared tilt.
  4. While the pointer is inside the field (.is-live) the transition is a quick 0.14s ease-out so it tracks closely; on release a long cubic-bezier with overshoot takes over, reading as a spring pulling the button home.
  5. Every length is a multiple of one base unit, --u, tied to the canvas — the field, the button, its lift and how far it travels — so the whole thing is the same share of a gallery card, the editor and a recording canvas. The numbers below are those units. The button is the model, so it is a big 150 × 112 tile with its label on two lines, and the field round it is a faint violet pad that reads on a dark stage and a light one alike.

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<br />me</span></button>
</div>

CSS 94 lines

.magnet {
  /* one base unit: every length below is a multiple of it, so the field is the same share of a
     card, the editor, a full screen and a recording canvas */
  --u: 0.38vmin;
  position: relative;
  display: grid;
  place-items: center;
  /* 220 × 160 outside, its border included: the glow's travel below stops at this edge */
  box-sizing: border-box;
  width: calc(220 * var(--u));
  height: calc(160 * var(--u));
  /* the field: a faint violet pad, the same tint on a dark stage and a light one (a grey dashed
     line was near invisible on dark and a hard black box on light) */
  border: calc(1.5 * var(--u)) solid rgb(139 108 255 / 0.3);
  border-radius: calc(26 * var(--u));
  background: radial-gradient(ellipse at 50% 50%, rgb(139 108 255 / 0.14), rgb(139 108 255 / 0.04) 72%);
  perspective: calc(800 * var(--u));
  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% - 70 * var(--u));
  left: calc(50% - 95 * var(--u));
  width: calc(190 * var(--u));
  height: calc(140 * var(--u));
  border-radius: 50%;
  background: radial-gradient(circle, rgb(46 230 214 / 0.55), transparent 68%);
  opacity: 0;
  pointer-events: none;
  /* the glow is 190 × 140 and the field 220 × 160, so it may travel 15 and 10: at full deflection
     it reaches the edge of the field and stops there, and the band holds the field, not the glow */
  transform: translate3d(calc(var(--mx, 0) * 15 * var(--u)), calc(var(--my, 0) * 10 * var(--u)), 0);
  transition: transform 0.7s cubic-bezier(0.3, 1.6, 0.5, 1), opacity 0.4s;
}

/* the button is the model, so it is big: a 150 × 112 tile, the label on two lines. It shifts toward
   the pointer and leans the way it is pulled; it floats 34 units above the field so its far,
   tipped-back edge still stays in front of the field's own plane */
.btn {
  position: relative;
  width: calc(150 * var(--u));
  height: calc(112 * var(--u));
  padding: 0;
  border: calc(1 * var(--u)) solid #a996ff;
  border-radius: calc(24 * var(--u));
  background: linear-gradient(140deg, #8b6cff, #bf5ed3);
  box-shadow: 0 calc(16 * var(--u)) calc(26 * var(--u)) calc(-14 * var(--u)) rgb(139 108 255 / 0.9);
  color: #fff;
  font: inherit;
  font-size: calc(34 * var(--u));
  font-weight: 800;
  line-height: 1;
  cursor: pointer;
  transform-style: preserve-3d;
  transform:
    translate3d(calc(var(--mx, 0) * 24 * var(--u)), calc(var(--my, 0) * 16 * var(--u)), calc(34 * var(--u)))
    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: calc(2 * var(--u)) solid #2ee6d6;
  outline-offset: calc(4 * var(--u));
}

/* 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 calc(6 * var(--u)) calc(10 * var(--u)) rgb(0 0 0 / 0.35);
  transform: translateZ(calc(30 * var(--u)));
  transition: transform 0.2s;
}

.btn:active span {
  transform: translateZ(calc(8 * var(--u))); /* 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);
Standalone snippet · plain CSS, no build step, no dependencies.