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.--u, tied to the canvas, and JS only ever writes angles and percentages, never lengths. So the card, its type and how far its layers float are the same share of a gallery card, the editor and a recording canvas.CSS custom properties set from JStranslateZ pop-out layersradial-gradient glare<div class="scene" tabindex="0" role="img" aria-label="Card that tilts toward the pointer, with a moving glare">
<div class="tilt">
<span class="chip"></span>
<b>Move your pointer</b>
<small>tilt · depth · glare</small>
</div>
</div>
.scene {
/* one base unit: every length below is a multiple of it, so the card is the same share of a
gallery card, the editor, a full screen and a recording canvas */
--u: 0.28vmin;
display: grid;
place-items: center;
width: 100%;
height: 100%;
perspective: calc(800 * var(--u));
}
.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: calc(2 * var(--u));
/* the card is 280 × 176 outside, a bank card's shape: its padding is inside that */
box-sizing: border-box;
width: calc(280 * var(--u));
height: calc(176 * var(--u));
padding: calc(20 * var(--u));
border-radius: calc(18 * var(--u));
color: #fff;
/* the card's own words, so they scale with the card and not with the page */
font: 500 calc(17 * var(--u))/1.35 Inter, system-ui, sans-serif;
/* deep enough for its white words (5.1:1 at the worst end): the bright #8b6cff to #ff4d9d was 3:1 */
background: linear-gradient(135deg, #6a45f5, #d1206f);
box-shadow: 0 calc(24 * var(--u)) calc(40 * var(--u)) calc(-18 * var(--u)) #8b6cff;
transform-style: preserve-3d;
/* the glow shadow hangs below the card and a tilt towards the pointer throws the near edge
further out, so what the card paints sits below its box: the lift centres the drawing */
transform: translateY(calc(-4 * var(--u))) 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(calc(40 * var(--u))); }
.tilt small { transform: translateZ(calc(22 * var(--u))); opacity: 0.85; }
.chip {
position: absolute;
top: calc(22 * var(--u));
left: calc(22 * var(--u));
width: calc(46 * var(--u));
height: calc(34 * var(--u));
border-radius: calc(7 * var(--u));
background: linear-gradient(135deg, #ffe08a, #d89b1d);
transform: translateZ(calc(54 * var(--u)));
}
/* the keyboard: Tab to it and it leans as it would for a pointer near the top right, even with
the pointer resting on the canvas (!important beats the pose the script writes inline) */
.scene:focus-visible .tilt { --rx: 8deg !important; --ry: 12deg !important; --mx: 80% !important; --my: 20% !important; }
.scene:focus-visible .tilt::after { opacity: 1; }
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');
});