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.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 {
perspective: 600px;
}
/* the button is the hit target and never moves */
.roll {
--h: 56px;
display: block;
width: 220px;
height: var(--h);
padding: 0;
border: 0;
background: none;
color: #fff;
font: 800 1.1rem 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: 6px;
backface-visibility: hidden;
}
.bar span:first-child {
background: #8b6cff;
transform: translateZ(calc(var(--h) / 2));
}
/* hidden at rest, otherwise its edge shows as a thin line under the button */
.bar span:last-child {
background: #ff4d9d;
opacity: 0;
transform: rotateX(-90deg) translateZ(calc(var(--h) / 2));
transition: opacity 0.1s;
}
.roll:hover .bar span:last-child,
.roll:focus-visible .bar span:last-child {
opacity: 1;
}
$h: 50px;
// The <button> is the hit target and NEVER moves. Only the bar inside it rolls.
// If the rolling element were also the hovered element, its hit area would rotate away from
// the pointer mid-animation, :hover would drop, and the button would snap back and forth.
.d-rollbutton {
display: block;
width: 190px;
height: $h;
padding: 0;
border: 0;
background: none;
color: #fff;
font-size: 16px;
font-weight: 800;
cursor: pointer;
transform-style: preserve-3d;
&__bar {
position: relative;
display: block;
width: 100%;
height: 100%;
pointer-events: none; // the pointer only ever "sees" the static button
transform-style: preserve-3d;
// Pull the bar back by half its depth so the front face sits exactly at z = 0.
transform: translateZ($h * -0.5);
transition: transform 0.45s cubic-bezier(0.3, 1.3, 0.5, 1);
span {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
border-radius: 6px;
backface-visibility: hidden;
}
// front face
span:first-child {
background: var(--accent);
transform: translateZ($h * 0.5);
}
// bottom face: rolls up to the front on hover.
// Hidden at rest, otherwise its edge shows as a thin line under the button.
span:last-child {
background: var(--hot);
opacity: 0;
transform: rotateX(-90deg) translateZ($h * 0.5);
transition: opacity 0.1s;
}
}
&:hover &__bar,
&:focus-visible &__bar {
transform: translateZ($h * -0.5) rotateX(90deg);
span:last-child {
opacity: 1;
}
}
}