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.
At rest it is only its Menu bar. Hover or focus it: the items swing down from their top edge, one after another.
Your edited version
transform-origin: top) folded up out of sight, rotateX(-90deg) with opacity: 0, and gives back the room it takes with a negative margin-bottom of its own height, so the folded list is no height at all. The items set their own light text colour, because their dark panel is dark on a light stage too.:hover / :focus of the menu they swing down flat to 0° and take their room back (margin-bottom: 0), with the same duration and easing, so each row grows exactly as its item swings into it.transition-delay: calc(var(--i) * 80ms) opens them one after another.perspective on the list gives the swing its depth.--u, tied to the canvas, text included, so the menu is the same share of a gallery card, the editor and a recording canvas. The stage centres the menu by its height, so the bar alone is centred at rest and the whole open menu is centred open: as the rows grow the bar rises by half of what they add, in step with them, with no motion of its own. The list has no gap under the bar (padding, not margin), so the pointer stays on the menu as it rises.rotateX(-90deg) → 0 with origin toptransition-delay from --itabindex + :focus for keyboards<div class="menu" tabindex="0">
<span>Menu</span>
<ul>
<li style="--i:0">Profile</li>
<li style="--i:1">Projects</li>
<li style="--i:2">Settings</li>
<li style="--i:3">Sign out</li>
</ul>
</div>
.menu {
/* one base unit: every length below is a multiple of it, so the menu is the same share of a
card, the editor, a full screen and a recording canvas */
--u: 0.29vmin;
width: calc(200 * var(--u));
cursor: pointer;
font: calc(16 * var(--u)) Inter, system-ui, sans-serif;
}
.menu > span {
display: block;
padding: calc(12 * var(--u)) calc(16 * var(--u));
border-radius: calc(10 * var(--u));
color: #fff;
font-weight: 800;
/* the brand gradient deep enough for white words (5.1:1 at its worst end): the bright #8b6cff to
#ff4d9d was 3.7:1 */
background: linear-gradient(120deg, #6a45f5, #d1206f);
}
/* the caret is drawn, not typed: ▾ is in neither font an export carries */
.menu > span::after {
content: '';
display: inline-block;
width: 0.6em;
height: 0.38em;
margin-left: 0.4em;
vertical-align: 0.12em;
background: currentColor;
clip-path: polygon(0 0, 100% 0, 50% 100%);
}
/* the list is in the flow, so the menu is as tall as what is open of it and the stage centres
exactly that: the bar alone at rest, bar and list open */
.menu ul {
margin: 0;
/* the gap under the bar is the list's own padding, so the pointer never falls between the two
as the menu rises; folded, the list takes no pointer, so hovering under the bar opens nothing */
padding: calc(4 * var(--u)) 0 0;
list-style: none;
perspective: calc(500 * var(--u));
pointer-events: none;
}
.menu:hover ul,
.menu:focus ul {
pointer-events: auto;
}
.menu li {
padding: calc(10 * var(--u)) calc(16 * var(--u));
line-height: calc(20 * var(--u)); /* with the padding and border, 42 units tall */
background: #141830;
border: calc(1 * var(--u)) solid #2a3054;
color: #eceefb; /* its own colour: the panel is dark on a light stage too */
opacity: 0;
transform-origin: top center;
/* at rest the items are folded up out of sight and give back their room, so the menu is only
its bar; hover swings them down from their top edge as their rows grow */
transform: rotateX(-90deg);
margin-bottom: calc(-42 * var(--u));
transition:
transform 0.35s cubic-bezier(0.3, 1.4, 0.5, 1),
margin-bottom 0.35s cubic-bezier(0.3, 1.4, 0.5, 1),
opacity 0.2s;
transition-delay: calc((3 - var(--i)) * 50ms); /* closing: bottom-up */
}
.menu:hover li,
.menu:focus li {
opacity: 1;
transform: rotateX(0deg);
margin-bottom: 0;
transition-delay: calc(var(--i) * 80ms); /* opening: top-down */
}