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

Radial action menu in pure CSS

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.

  • 128 lines of CSS
  • 12 lines of HTML
  • No JavaScript
  • No dependencies
  • MIT licensed

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. Every item starts tucked directly behind the 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.
  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 its own arc.
  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 visibility: hidden and pointer-events: none so Tab and clicks skip them until the menu is actually 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" />
    <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>

CSS 128 lines

.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;
}

Sass source 132 lines

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