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.
A floating action button built from a checkbox and its label. Closed, it is only the + button; checked, its five actions walk out along their spokes into a ring round it, coming forward one after another.
Your edited version
<label>, which is why only the plus icon inside needs to rotate on :checked, not the whole button.scale(0.4)) and pulled back in Z. :checked swaps in a transform list with the same functions but different numbers, so the browser animates each one independently.rotate(a) translateX(r) rotate(-a) walks a point out along a straight spoke at angle a while the trailing rotate(-a) cancels the turn, so every icon stays upright as it travels. Of those three, only r changes between closed and open (0 to 70 units), so each item moves straight out along its spoke; the translateZ and scale after them bring it forward and up to full size as it goes. The five spokes are 72° apart, a full ring round the button.--i staggers the opening so the items fan out one after another; on close the delay is reversed ((4 - var(--i))) so the last item to open is the first to leave.opacity: 0, visibility: hidden and pointer-events: none, so they are not seen and Tab and clicks skip them until the menu is actually open.--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 button sits in the middle and the items open into a full ring round it, so the menu is centred closed and open alike, and opening never moves the button. At rest it is only the button, at the size of a button: a control that opens into something rests small by design and fills the frame open.rotate(a) translateX(r) rotate(-a)matching transform lists animate per functiontransition-delay from --icheckbox + label toggle<div class="scene">
<div class="radial">
<input type="checkbox" id="radial-toggle" aria-label="Open the action menu" />
<label class="fab" for="radial-toggle" title="Actions"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14"/><path d="M5 12h14"/></svg></label>
<span class="ring"></span>
<button type="button" class="item" style="--i:0;--c:#ff4d9d" aria-label="Like" title="Like"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 14c1.5-1.5 3-3.2 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.8 0-3 .5-4.5 2-1.5-1.5-2.7-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4 3 5.5l7 7Z"/></svg></button>
<button type="button" class="item" style="--i:1;--c:#ffb547" aria-label="Edit" title="Edit"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg></button>
<button type="button" class="item" style="--i:2;--c:#2ee6d6" aria-label="Share" title="Share"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v13"/><path d="m7 8 5-5 5 5"/><path d="M5 14v5a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-5"/></svg></button>
<button type="button" class="item" style="--i:3;--c:#8b6cff" aria-label="Copy" title="Copy"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="13" height="13" x="8" y="8" rx="2"/><path d="M5 16a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2"/></svg></button>
<button type="button" class="item" style="--i:4;--c:#ff4d9d" aria-label="Search" title="Search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.5-4.5"/></svg></button>
</div>
</div>
.scene {
/* 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.39vmin;
perspective: calc(800 * var(--u));
}
.radial {
position: relative;
width: calc(200 * var(--u));
height: calc(200 * var(--u));
transform-style: preserve-3d;
/* lean the whole menu back a little so the depth can be seen; the five spokes reach further up
than down (one straight up, two at the bottom corners), so it sits a little low to be centred */
transform: translateY(calc(6 * var(--u))) rotateX(16deg);
}
/* the real checkbox: invisible, but it holds the open/closed state and takes the keyboard */
.radial > input {
position: absolute;
bottom: 0;
left: 50%;
width: calc(1 * var(--u));
height: calc(1 * var(--u));
margin: 0;
opacity: 0;
pointer-events: none;
}
/* the button is the label: it is the hit target and stays put in the middle, only the plus
inside turns */
.fab {
position: absolute;
top: calc(50% - 32 * var(--u));
left: calc(50% - 32 * var(--u));
display: grid;
place-items: center;
width: calc(64 * var(--u));
height: calc(64 * var(--u));
border-radius: 50%;
background: linear-gradient(140deg, #8b6cff, #ff4d9d);
box-shadow: 0 calc(10 * var(--u)) calc(22 * var(--u)) calc(-8 * var(--u)) rgb(139 108 255 / 0.8);
color: #fff;
cursor: pointer;
transform: translateZ(calc(1 * var(--u))); /* just in front of the items' resting place */
}
.fab svg {
width: calc(28 * var(--u));
height: calc(28 * var(--u));
pointer-events: none;
transition: transform 0.45s cubic-bezier(0.3, 1.5, 0.5, 1);
}
.radial > input:focus-visible ~ .fab {
outline: calc(2 * var(--u)) solid #2ee6d6;
outline-offset: calc(3 * var(--u));
}
.radial > input:checked ~ .fab svg {
transform: rotate(135deg);
}
/* a faint ring that shows the circle the items travel to, all the way round the button */
.ring {
position: absolute;
top: calc(50% - 70 * var(--u));
left: calc(50% - 70 * var(--u));
width: calc(140 * var(--u));
height: calc(140 * var(--u));
border: calc(1 * var(--u)) dashed rgb(139 108 255 / 0.55);
border-radius: 50%;
opacity: 0;
pointer-events: none;
transform: scale(0.3);
transition: transform 0.5s cubic-bezier(0.3, 1.3, 0.5, 1), opacity 0.3s;
}
.radial > input:checked ~ .ring {
opacity: 1;
transform: scale(1);
}
/* every item waits hidden behind the button, centre on centre; its place on the ring is
turn-to-angle, walk out, turn back (so the icon stays upright) */
.item {
--a: calc(-90deg + var(--i) * 72deg); /* five spokes, 72deg apart, the first straight up */
position: absolute;
top: calc(50% - 18 * var(--u));
left: calc(50% - 18 * var(--u));
display: grid;
place-items: center;
width: calc(36 * var(--u));
height: calc(36 * var(--u));
padding: 0;
border: calc(1 * var(--u)) solid color-mix(in srgb, var(--c) 80%, transparent);
border-radius: 50%;
background: color-mix(in srgb, var(--c) 30%, #0b0d18);
color: #eceefb;
cursor: pointer;
opacity: 0;
visibility: hidden; /* closed items must not be reachable by Tab */
pointer-events: none;
transform: rotate(var(--a)) translateX(0) rotate(calc(var(--a) * -1)) translateZ(calc(-30 * var(--u))) rotateX(0deg) scale(0.4);
/* closing: the last item leaves first */
transition:
transform 0.35s ease-in calc((4 - var(--i)) * 35ms),
opacity 0.25s linear calc((4 - var(--i)) * 35ms + 0.1s),
visibility 0s linear 0.55s;
}
.item svg {
width: calc(17 * var(--u));
height: calc(17 * var(--u));
}
.item:hover,
.item:focus-visible {
background: color-mix(in srgb, var(--c) 65%, #0b0d18);
}
.radial > input:checked ~ .item {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: rotate(var(--a)) translateX(calc(70 * var(--u))) rotate(calc(var(--a) * -1)) translateZ(calc(26 * var(--u))) rotateX(0deg) scale(1);
/* opening: staggered by index, with a little overshoot */
transition:
transform 0.55s cubic-bezier(0.3, 1.5, 0.5, 1) calc(var(--i) * 45ms),
opacity 0.2s linear calc(var(--i) * 45ms),
visibility 0s;
}