Pure CSS Radial Menu with Native Select Element

Category: CSS & CSS3 , Menu & Navigation | March 18, 2026
Authorcbolson
Last UpdateMarch 18, 2026
LicenseMIT
Views39 views
Pure CSS Radial Menu with Native Select Element

A pure CSS circular navigation component that converts a native HTML <select> element into a fully styled radial menu.

It positions clickable items in a ring around a central toggle button, animates their entry with smooth CSS transitions, and adapts to light and dark color schemes through native CSS color functions.

Features:

  • The native <select> element manages open/close state natively.
  • appearance: base-select styling.
  • Automatic radial positioning.
  • @starting-style entry animations.
  • Staggered animation support.
  • CSS custom variables.
  • Automatic light/dark mode.
  • SVG icon support.
  • Graceful fallback.

How to Use It:

1. Create the HTML Structure. The component is a native <select> element. The first <option> acts as the center toggle/close icon. Every subsequent <option> becomes a radial menu item. Each item holds an inline SVG and a <span> label.

<select>
  <!-- The <button> renders the currently selected option's icon -->
  <button>
    <selectedcontent></selectedcontent>
  </button>
  <!-- First option: stays at center as the menu/close toggle -->
  <option>
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
      <g fill="none" stroke="currentColor" stroke-width="2"
         stroke-linecap="round" stroke-linejoin="round">
        <path d="M4 6l16 0" />
        <path d="M4 12l16 0" />
        <path d="M4 18l16 0" />
      </g>
    </svg>
    <span>Menu</span>
  </option>
  <!-- Radial item: Home -->
  <option>
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
      <path stroke="none" d="M0 0h24v24H0z" fill="none"/>
      <path d="M12.707 2.293l9 9c.63 .63 .184 1.707 -.707 1.707h-1v6a3 3 0 0 1
               -3 3h-1v-7a3 3 0 0 0 -2.824 -2.995l-.176 -.005h-2a3 3 0 0 0 -3
               3v7h-1a3 3 0 0 1 -3 -3v-6h-1c-.89 0 -1.337 -1.077 -.707
               -1.707l9 -9a1 1 0 0 1 1.414 0m.293 11.707a1 1 0 0 1 1 1v7h-4v-7a1
               1 0 0 1 .883 -.993l.117 -.007z"/>
    </svg>
    <span>Home</span>
  </option>
  <!-- Radial item: Messages -->
  <option>
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
      <path stroke="none" d="M0 0h24v24H0z" fill="none"/>
      <path d="M18 3a4 4 0 0 1 4 4v8a4 4 0 0 1 -4 4h-4.724l-4.762 2.857a1 1 0 0
               1 -1.508 -.743l-.006 -.114v-2h-1a4 4 0 0 1 -3.995 -3.8l-.005
               -.2v-8a4 4 0 0 1 4 -4zm-4 9h-6a1 1 0 0 0 0 2h6a1 1 0 0 0 0
               -2m2 -4h-8a1 1 0 1 0 0 2h8a1 1 0 0 0 0 -2"/>
    </svg>
    <span>Messages</span>
  </option>
  <!-- Add more <option> items following the same pattern -->
  <!-- sibling-index() auto-recalculates the ring for each new item -->
</select>

2. Add the CSS to your webpage.

@layer demo;
/* ══════════════════════════════════════════════════════════
   DEMO LAYER — radial menu configuration and styles
   ══════════════════════════════════════════════════════════ */
@layer demo {
  :root {
    /* Allows CSS to animate to/from keyword sizes like 'auto' */
    interpolate-size: allow-keywords;
    /* Global animation speed — applies to all transitions */
    --transition-duration: 300ms;
    /* ── Toggle button ── */
    --select-width: 100px;
    --select-padding: .25rem .75rem;
    --select-text-color: light-dark(rgb(113 113 123), rgb(245 245 245));
    --select-bg-color: light-dark(rgb(245 245 245), dodgerblue);
    --select-bg-color-hover: light-dark(rgb(228 228 231), rgb(0 105 168));
    --select-border-color: 1px solid light-dark(rgb(212 212 216), rgb(0 105 168));
    /* ── Picker container (the floating options panel) ── */
    --option-list-size: calc(var(--select-width) * 4); /* total ring area diameter */
    --option-list-bg-color: light-dark(rgb(226 232 240), rgb(29 41 61));
    --option-list-border: 1px solid var(--clr-lines);
    --option-list-padding: 1rem;
    --option-list-radius: 10px;
    /* ── Individual option items ── */
    --option-size: var(--select-width);
    --option-padding: 2rem;
    --option-offset: calc(var(--select-width) * 1); /* ring radius: distance from center */
    --option-radius: 9in;                           /* 9in = full circle (pill trick) */
    --option-font-size: .8rem;
    --option-border: none;
    --option-border-color: var(--clr-lines);
    --option-bg-color: light-dark(rgb(202 213 226), rgb(69 85 108));
    --option-bg-color-hover: rgb(0 105 168);
    --option-bg-color-selected: deeppink;
    --option-text-color: light-dark(rgb(10 113 123), rgb(255 255 255));
    --option-text-color-hover: white;
    --option-text-color-selected: white;
    /* ── selectedcontent (icon displayed inside the button) ── */
    --selected-element-radius: var(--option-radius);
    --selected-element-padding: var(--option-padding);
    --selected-element-bg-color: var(--option-bg-color);
    --selected-element-text-color: var(--option-text-color);
    --selected-element-border-color: var(--option-border-color);
  }
  /* ── Fallback: standard dropdown for non-supporting browsers ── */
  select {
    width: var(--select-width);
    margin-inline: auto;
    background-color: var(--select-bg-color);
    color: var(--select-text-color);
    padding: var(--select-padding);
    border: var(--select-border-color);
    outline: 1px dashed transparent;
    transition: scale var(--transition-duration) ease-in-out;
    &:hover, &:focus, &:active { scale: 1.1; }
    &:focus-visible {
      outline: 1px dashed dodgerblue;
      outline-offset: 5px;
    }
    /* Hide SVGs and the first "menu" option in fallback mode */
    selectedcontent > svg, option > svg { display: none; }
    option:first-of-type { display: none; }
  }
  /* ══════════════════════════════════════════════════════════
     Radial styles — only applied in supporting browsers
     ══════════════════════════════════════════════════════════ */
  @supports (appearance: base-select) {
    /* Opt both the select and its picker into base-select rendering */
    select, ::picker(select) {
      appearance: base-select;
    }
    select {
      background-color: transparent;
      border: none;
      padding: 0;
      border-radius: 9in; /* circular button */
      /* Remove the default dropdown arrow */
      &::picker-icon { display: none; }
      /* ── Labels: hidden by default, revealed on hover ── */
      selectedcontent > span,
      option > span {
        display: block;
        position: absolute;
        bottom: 1em;
        left: 50%;
        translate: -50% 50%;
        transition: all var(--transition-duration) ease-in-out;
        opacity: 0;
      }
      /* ── SVG icons: shown in both button and options ── */
      selectedcontent > svg,
      option > svg {
        display: block;
        width: 100%;
        aspect-ratio: 1;
        transition:
          scale var(--transition-duration) ease-in-out,
          translate var(--transition-duration) ease-in-out;
      }
      /* ── Toggle button ── */
      button {
        width: var(--select-width);
        aspect-ratio: 1;
        selectedcontent {
          display: grid;
          place-content: center;
          padding: var(--selected-element-padding);
          border: 1px solid var(--selected-element-border-color);
          border-radius: var(--selected-element-radius);
          background-color: var(--selected-element-bg-color);
          color: var(--selected-element-text-color);
          transition:
            opacity var(--transition-duration) ease-in-out,
            scale var(--transition-duration) ease-in-out;
        }
      }
      /* Fade and shrink the button icon when the picker opens */
      &:open selectedcontent {
        opacity: 0;
        scale: .5;
      }
      /* ── Picker container ── */
      &::picker(select) {
        /* sibling-count() returns the total option count; subtract 1 for the menu item */
        --items: calc(sibling-count() - 1);
        pointer-events: none;
        position-area: span-all; /* anchor picker over the select element */
        width: var(--option-list-size);
        aspect-ratio: 1;
        border: none;
        background: transparent;
        opacity: 0;
        scale: 0;
        transition: all var(--transition-duration) allow-discrete;
        backdrop-filter: blur(2px);
      }
      /* Animate picker panel in when open */
      &:open::picker(select) {
        pointer-events: auto;
        opacity: 1;
        scale: 1;
        /* Entry state for the opening animation */
        @starting-style {
          opacity: 0;
          scale: .4;
        }
      }
      /* ── Option items ── */
      option {
        /* First option stays at center as the close toggle */
        &:first-of-type {
          display: block;
          scale: .5;
          transition: 0ms; /* instant — no animation for the center icon */
        }
        /* All other items are distributed around the ring */
        &:not(:first-of-type) {
          --i: calc(sibling-index());
          /* Divide 360° evenly across all non-menu items */
          --angle: calc(360deg / calc(var(--items) - 1) * var(--i));
          transform:
            rotate(var(--angle))           /* rotate to calculated position */
            translate(var(--option-offset)) /* push outward by ring radius */
            rotate(calc(var(--angle) * -1)); /* counter-rotate: keeps icon upright */
        }
        /* Common layout for all options */
        grid-area: 1/1;
        position: absolute;
        inset: 50%;
        translate: -50% -50%;
        cursor: pointer;
        width: var(--option-size);
        aspect-ratio: 1;
        padding: var(--option-padding);
        border-radius: var(--option-radius);
        border: 1px solid var(--option-border-color);
        color: var(--option-text-color);
        background-color: var(--option-bg-color);
        isolation: isolate;
        outline: 1px dashed transparent;
        /* Remove the native checkmark glyph */
        &::checkmark { display: none; }
        /* Highlight the currently selected item */
        &:checked {
          background: var(--option-bg-color-selected);
          color: var(--option-text-color-selected);
        }
        transition:
          color var(--transition-duration) ease-in-out,
          opacity var(--transition-duration) ease-in-out,
          outline var(--transition-duration) ease-in-out,
          /* stagger delay: each item waits (index - 1) × 200ms */
          scale var(--transition-duration) ease-in-out calc((var(--i) - 1) * 200ms),
          background-color var(--transition-duration) ease-in-out;
      }
      /* Hover / focus: reveal label, shift icon upward */
      option:hover,
      option:focus-visible {
        background-color: var(--option-bg-color-hover);
        color: var(--option-text-color-hover);
        span { opacity: 1; translate: -50% -2ex; }
        svg  { scale: .5; translate: 0 -2ex; }
        &:focus-visible {
          outline: 1px dashed dodgerblue;
          outline-offset: 5px;
        }
      }
    }
    /* ── Stagger animation — active when checkbox is checked ── */
    /* The :has() selector detects checkbox state; no JS needed */
    body:has(input#stagger-toggle:checked) select:open option {
      @starting-style {
        scale: 0;
      }
    }
  } /* end @supports */
} /* end @layer demo */

3. Customize the radial menu with the followng CSS variables:

  • --transition-duration (time): Global animation speed for all transitions. Default: 300ms.
  • --select-width (length): Diameter of the circular toggle button. This drives derived calculations for item size and ring geometry. Default: 100px.
  • --select-padding (length): Padding applied to the fallback <select> element only. Default: .25rem .75rem.
  • --select-text-color (color): Text color of the fallback <select>. Default: light-dark(rgb(113 113 123), rgb(245 245 245)).
  • --select-bg-color (color): Background color of the fallback <select>. Default: light-dark(rgb(245 245 245), dodgerblue).
  • --select-bg-color-hover (color): Background of the fallback <select> on hover. Default: light-dark(rgb(228 228 231), rgb(0 105 168)).
  • --select-border-color (border shorthand): Border of the fallback <select>. Default: 1px solid light-dark(...).
  • --option-list-size (length): Total diameter of the floating picker container. Default: calc(var(--select-width) * 4).
  • --option-list-bg-color (color): Background color of the picker container. Default: light-dark(rgb(226 232 240), rgb(29 41 61)).
  • --option-list-border (border shorthand): Border of the picker container. Default: 1px solid var(--clr-lines).
  • --option-list-padding (length): Padding inside the picker container. Default: 1rem.
  • --option-list-radius (length): Border radius of the picker container. Default: 10px.
  • --option-size (length): Width and height of each circular option button. Default: var(--select-width).
  • --option-padding (length): Padding inside each option item. Default: 2rem.
  • --option-offset (length): Outward translation applied to each option. This is the ring radius. Default: calc(var(--select-width) * 1).
  • --option-radius (length): Border radius of each option. Use 9in for a full circle. Default: 9in.
  • --option-font-size (length): Font size of option labels. Default: .8rem.
  • --option-border (border shorthand): Border style of option items. Default: none.
  • --option-border-color (color): Border color of option items. Default: var(--clr-lines).
  • --option-bg-color (color): Default background color of option items. Default: light-dark(rgb(202 213 226), rgb(69 85 108)).
  • --option-bg-color-hover (color): Background of an option on hover. Default: rgb(0 105 168).
  • --option-bg-color-selected (color): Background of the currently selected option. Default: deeppink.
  • --option-text-color (color): Default text and icon color of option items. Default: light-dark(rgb(10 113 123), rgb(255 255 255)).
  • --option-text-color-hover (color): Text color on hover. Default: white.
  • --option-text-color-selected (color): Text color of the selected option. Default: white.
  • --selected-element-radius (length): Border radius of the <selectedcontent> block inside the toggle button. Default: var(--option-radius).
  • --selected-element-padding (length): Padding of the <selectedcontent> block. Default: var(--option-padding).
  • --selected-element-bg-color (color): Background of the <selectedcontent> block. Default: var(--option-bg-color).
  • --selected-element-text-color (color): Text color of the <selectedcontent> block. Default: var(--option-text-color).
  • --selected-element-border-color (color): Border color of the <selectedcontent> block. Default: var(--option-border-color).

You Might Be Interested In:


Leave a Reply