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 than the button’s own lift — same tilt, more travel — so it visibly slides apart from its cap: parallax from one shared tilt..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.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 me</span></button>
</div>
.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;
}
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);
// 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;
}
}