Pure CSS Buttons & forms #controls #form-hack #menu #expands

Radial action menu in pure CSS

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.

  • 132 lines of CSS
  • 12 lines of HTML
  • No JavaScript
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. A real checkbox holds the open/closed state and takes the keyboard; the button visitors see is its <label>, which is why only the plus icon inside needs to rotate on :checked, not the whole button.
  2. Closed, it is only the button: every item waits hidden directly behind it, centre on centre, shrunk (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.
  3. 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.
  4. --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.
  5. Closed items get 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.
  6. Every length is a multiple of one base unit, --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.

Key techniques

  • rotate(a) translateX(r) rotate(-a)
  • matching transform lists animate per function
  • transition-delay from --i
  • checkbox + label toggle

Copy-paste code

HTML 12 lines

<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>

CSS 132 lines

.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;
}
Standalone snippet · plain CSS, no build step, no dependencies.