Magnetic button
CSS + JSInside 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.
JS writes the pointer position into custom properties; CSS turns them into tilt, pop-out and glare.
Your edited version
--rx, --ry, --mx, --my.rotateX/rotateY) and for the centre of a radial-gradient glare.translateZ float above the card, producing parallax for free.CSS custom properties set from JStranslateZ pop-out layersradial-gradient glare<div class="scene">
<div class="tilt">
<span class="chip"></span>
<b>Move your pointer</b>
<small>tilt · depth · glare</small>
</div>
</div>
.scene {
display: grid;
place-items: center;
width: 100vw;
height: 100vh;
perspective: 800px;
}
.tilt {
--rx: 0deg; --ry: 0deg; /* tilt — set from JS */
--mx: 50%; --my: 50%; /* glare — set from JS */
position: relative;
display: grid;
align-content: end;
gap: 2px;
width: 280px;
height: 176px;
padding: 20px;
border-radius: 18px;
color: #fff;
font-family: system-ui;
background: linear-gradient(135deg, #8b6cff, #ff4d9d);
box-shadow: 0 24px 40px -18px #8b6cff;
transform-style: preserve-3d;
transform: rotateX(var(--rx)) rotateY(var(--ry));
transition: transform 0.6s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tilt.is-live { transition-duration: 0.08s; }
.tilt::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: radial-gradient(circle at var(--mx) var(--my),
rgb(255 255 255 / 0.55), transparent 55%);
opacity: 0;
transition: opacity 0.3s;
}
.tilt.is-live::after { opacity: 1; }
/* children float above the surface */
.tilt b { transform: translateZ(40px); }
.tilt small { transform: translateZ(22px); opacity: 0.85; }
.chip {
position: absolute;
top: 22px;
left: 22px;
width: 46px;
height: 34px;
border-radius: 7px;
background: linear-gradient(135deg, #ffe08a, #d89b1d);
transform: translateZ(54px);
}
const scene = document.querySelector('.scene');
const card = document.querySelector('.tilt');
const MAX = 20; // degrees
scene.addEventListener('pointermove', (e) => {
const r = scene.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width - 0.5; // -0.5 … 0.5
const y = (e.clientY - r.top) / r.height - 0.5;
card.style.setProperty('--ry', x * MAX * 2 + 'deg');
card.style.setProperty('--rx', -y * MAX * 2 + 'deg');
card.style.setProperty('--mx', (x + 0.5) * 100 + '%');
card.style.setProperty('--my', (y + 0.5) * 100 + '%');
card.classList.add('is-live');
});
scene.addEventListener('pointerleave', () => {
card.classList.remove('is-live');
card.style.removeProperty('--rx');
card.style.removeProperty('--ry');
});
.d-tilt {
display: grid;
place-items: center;
width: 100%;
height: 100%;
transform-style: preserve-3d;
&__card {
// JS writes --rx / --ry (tilt) and --mx / --my (glare position). CSS does the rest.
--rx: 0deg;
--ry: 0deg;
--mx: 50%;
--my: 50%;
position: relative;
display: grid;
align-content: end;
gap: 2px;
width: 210px;
height: 132px;
padding: 16px;
border-radius: 16px;
background: linear-gradient(135deg, var(--accent), var(--hot));
color: #fff;
box-shadow: 0 24px 40px -18px var(--accent);
transform-style: preserve-3d;
transform: rotateX(var(--rx)) rotateY(var(--ry));
// Slow ease back to rest; near-instant while the pointer is driving.
transition: transform 0.6s cubic-bezier(0.2, 0.8, 0.2, 1);
&.is-live {
transition-duration: 0.08s;
}
// glare
&::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: radial-gradient(circle at var(--mx) var(--my), rgb(255 255 255 / 0.55), transparent 55%);
opacity: 0;
transition: opacity 0.3s;
}
&.is-live::after {
opacity: 1;
}
// Children float above the card surface.
b {
font-size: 16px;
transform: translateZ(36px);
}
small {
opacity: 0.85;
transform: translateZ(20px);
}
}
&__chip {
position: absolute;
top: 18px;
left: 18px;
width: 38px;
height: 28px;
border-radius: 6px;
background: linear-gradient(135deg, #ffe08a, #d89b1d);
transform: translateZ(48px);
}
}