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.
The button is a bar with two faces; hover rolls the second one into view.
Your edited version
<button> is a static hit target; the bar inside it rolls and has pointer-events: none. If the button itself rotated, its hit area would turn away from the pointer mid-roll, :hover would drop, and it would snap back and forth.rotateX(-90deg)).rotateX(90deg), rolling the bottom face up to the front.translateZ(-h/2) on the bar keeps the front face at z = 0, so the text stays the same size and sharp.--u, tied to the canvas, so the bar, its type and the depth it rolls through are the same share of a gallery card, the editor and a recording canvas. The numbers below are those units.√2 × h tall. That corner, not the resting bar, is what has to fit the band — which is why the bar rests at about 50vmin and touches 70vmin only in passing.static hit target, moving childtwo faces 90° aparttranslateZ(-h/2) keeps it in place<div class="scene">
<button class="roll" type="button">
<span class="bar">
<span>Hover me</span>
<span>Let's go</span>
</span>
</button>
</div>
.scene {
/* one base unit: every length below is a multiple of it, so the bar is the same share of a
card, the editor, a full screen and a recording canvas */
--u: 0.32vmin;
perspective: calc(600 * var(--u));
}
/* the button is the hit target and never moves */
.roll {
--h: calc(150 * var(--u));
display: block;
width: calc(230 * var(--u));
height: var(--h);
padding: 0;
border: 0;
background: none;
color: #fff;
font: 800 calc(24 * var(--u)) Inter, system-ui;
cursor: pointer;
transform-style: preserve-3d;
}
/* only the bar inside it rolls */
.bar {
position: relative;
display: block;
width: 100%;
height: 100%;
pointer-events: none;
transform-style: preserve-3d;
transform: translateZ(calc(var(--h) / -2));
transition: transform 0.45s cubic-bezier(0.3, 1.3, 0.5, 1);
}
.roll:hover .bar,
.roll:focus-visible .bar {
transform: translateZ(calc(var(--h) / -2)) rotateX(90deg);
}
.bar span {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: calc(12 * var(--u));
backface-visibility: hidden;
}
.bar span:first-child {
background: #8b6cff;
transform: translateZ(calc(var(--h) / 2));
}
/* the arrow after "Let's go" is drawn, not typed: → is in neither font an export carries, so
every device, and the export service, drew one of its own. Side by side with the words */
.bar span:last-child {
grid-auto-flow: column;
justify-content: center;
column-gap: 0.4em;
}
.bar span:last-child::after {
content: '';
width: 0.85em;
height: 0.7em;
background: currentColor;
clip-path: polygon(0 40%, 58% 40%, 58% 8%, 100% 50%, 58% 92%, 58% 60%, 0 60%);
}
/* At rest it is the bottom of the prism, facing down and away from you, so backface-visibility
already hides it. It is never faded: a fade quicker than the roll turned it dark in one step
as the pointer left, while it was still rolling away in plain sight. */
.bar span:last-child {
background: #ff4d9d;
transform: rotateX(-90deg) translateZ(calc(var(--h) / 2));
}