Push button
Pure CSSPress it. The edge is a generated box-shadow stack that collapses on :active.
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.
Your edited version
--mx / --my from -1 to 1. Every visible motion is CSS reading those two custom properties.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.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..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.--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.pointer → --mx / --mytranslate3d + rotate from the same two numberslabel at translateZ for parallaxovershoot easing as a spring<div class="magnet">
<i class="glow"></i>
<button type="button" class="btn"><span>Pull<br />me</span></button>
</div>
.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;
}
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);