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. Checked, five actions walk out along their spokes onto an arc, flipping up as they come 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), pulled back in Z, flipped with rotateX(-100deg)). :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 its own arc.--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.visibility: hidden and pointer-events: none so Tab and clicks skip them until the menu is actually 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" />
<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>
<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>
</div>
</div>
.scene {
perspective: 800px;
}
.radial {
position: relative;
width: 212px;
height: 134px;
transform-style: preserve-3d;
transform: rotateX(16deg); /* lean the whole menu back a little so the depth can be seen */
}
/* the real checkbox: invisible, but it holds the open/closed state and takes the keyboard */
.radial > input {
position: absolute;
bottom: 0;
left: 50%;
width: 1px;
height: 1px;
margin: 0;
opacity: 0;
pointer-events: none;
}
/* the button is the label: it is the hit target and stays put, only the plus inside turns */
.fab {
position: absolute;
bottom: 0;
left: calc(50% - 24px);
display: grid;
place-items: center;
width: 48px;
height: 48px;
border-radius: 50%;
background: linear-gradient(140deg, #8b6cff, #ff4d9d);
box-shadow: 0 10px 22px -8px rgb(139 108 255 / 0.8);
color: #fff;
cursor: pointer;
transform: translateZ(1px); /* just in front of the items' resting place */
}
.fab svg {
width: 22px;
height: 22px;
pointer-events: none;
transition: transform 0.45s cubic-bezier(0.3, 1.5, 0.5, 1);
}
.radial > input:focus-visible ~ .fab {
outline: 2px solid #2ee6d6;
outline-offset: 3px;
}
.radial > input:checked ~ .fab svg {
transform: rotate(135deg);
}
/* a faint ring that shows the arc the items travel to */
.ring {
position: absolute;
bottom: 24px;
left: calc(50% - 80px);
width: 160px;
height: 80px; /* the upper half of a circle around the button */
border: 1px dashed rgb(139 108 255 / 0.55);
border-bottom: 0;
border-radius: 80px 80px 0 0;
opacity: 0;
pointer-events: none;
transform: scale(0.3);
transform-origin: 50% 100%;
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 starts hidden behind the button, centre on centre; its place on the arc is
turn-to-angle, walk out, turn back (so the icon stays upright) */
.item {
--a: calc(-160deg + var(--i) * 35deg);
position: absolute;
bottom: 6px;
left: calc(50% - 18px);
display: grid;
place-items: center;
width: 36px;
height: 36px;
padding: 0;
border: 1px 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(-30px) rotateX(-100deg) 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: 17px;
height: 17px;
}
.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(80px) rotate(calc(var(--a) * -1)) translateZ(26px) 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;
}
$fab: 48px;
$item: 36px;
$radius: 80px;
.d-radial {
position: relative;
width: 212px;
height: 134px;
transform-style: preserve-3d;
transform: rotateX(16deg); // lean the whole menu back a little so the depth can be seen
// The real checkbox: invisible, but it holds the open/closed state and takes the keyboard.
> input {
position: absolute;
bottom: 0;
left: 50%;
width: 1px;
height: 1px;
margin: 0;
opacity: 0;
pointer-events: none;
}
// The button is the label. It is the hit target and stays put; only the plus inside turns.
&__fab {
position: absolute;
bottom: 0;
left: calc(50% - #{$fab * 0.5});
display: grid;
place-items: center;
width: $fab;
height: $fab;
border-radius: 50%;
background: linear-gradient(140deg, var(--accent), var(--hot));
box-shadow: 0 10px 22px -8px color-mix(in srgb, var(--accent) 80%, transparent);
color: #fff;
cursor: pointer;
transform: translateZ(1px); // just in front of the items' resting place
svg {
width: 22px;
height: 22px;
pointer-events: none;
transition: transform 0.45s cubic-bezier(0.3, 1.5, 0.5, 1);
}
}
& > input:focus-visible ~ .d-radial__fab {
outline: 2px solid var(--accent-2);
outline-offset: 3px;
}
& > input:checked ~ .d-radial__fab svg {
transform: rotate(135deg);
}
// A faint ring that shows the arc the items travel to.
&__ring {
position: absolute;
bottom: $fab * 0.5;
left: calc(50% - #{$radius});
width: $radius * 2;
height: $radius; // the upper half of a circle around the button
border: 1px dashed color-mix(in srgb, var(--accent) 55%, transparent);
border-bottom: 0;
border-radius: $radius $radius 0 0;
opacity: 0;
pointer-events: none;
transform: scale(0.3);
transform-origin: 50% 100%;
transition:
transform 0.5s cubic-bezier(0.3, 1.3, 0.5, 1),
opacity 0.3s;
}
& > input:checked ~ .d-radial__ring {
opacity: 1;
transform: scale(1);
}
// Every item starts hidden behind the button, centre on centre. Its place on the arc is
// turn-to-angle, walk out, turn back (so the icon stays upright). Closed and open use the SAME
// list of functions, so the browser animates each one separately: the walk follows a straight
// spoke while the item flips up around X and comes forward.
&__item {
--a: calc(-160deg + var(--i) * 35deg);
position: absolute;
bottom: ($fab - $item) * 0.5;
left: calc(50% - #{$item * 0.5});
display: grid;
place-items: center;
width: $item;
height: $item;
padding: 0;
border: 1px solid color-mix(in srgb, var(--c) 80%, transparent);
border-radius: 50%;
background: color-mix(in srgb, var(--c) 30%, var(--surface-solid));
color: var(--text);
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(-30px) rotateX(-100deg) 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;
svg {
width: 17px;
height: 17px;
}
&:hover,
&:focus-visible {
background: color-mix(in srgb, var(--c) 65%, var(--surface-solid));
}
}
& > input:checked ~ .d-radial__item {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: rotate(var(--a)) translateX($radius) rotate(calc(var(--a) * -1)) translateZ(26px) 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;
}
}