Styling Form Controls Without the Headache

Anyone who has tried to style a native <select> element knows the pain. The dropdown arrow, the listbox, and even basic layout all resist CSS customization. MDN sums it up bluntly: the element's internal structure is complex and hard to control. Developers often reach for JavaScript libraries or manually build dropdowns with WAI-ARIA semantics just to get something presentable.

The Open UI community group has been working on a solution. Their proposal is a new experimental element, <selectmenu>, that exposes the internal parts of a selection menu for styling. Unlike <select>, this element is not meant to replace anything. Instead, it offers a customizable alternative that gives developers the control they've been missing.

To demonstrate what this enables, we'll build something that would be impossible with CSS alone: a radial selection menu. Alongside <selectmenu>, we'll also tap into two more experimental features — the HTML Popover API and the CSS Anchor Positioning API, which lets us tether one element's size and position to another. For following along, Chrome Canary works best, with the "Experimental Web Platform Features" flag enabled at chrome://flags.

Understanding the Selectmenu Parts

The <selectmenu> element exposes its internal structure through CSS parts, meaning you can target individual components directly instead of wrestling with the whole control. There are currently six parts available:

  • <selectmenu>: the top-level selector that wraps the button and listbox.
  • button: toggles the visibility of the listbox between open and closed.
  • selected-value: displays the currently selected option.
  • marker: the dropdown indicator icon, typically a downward arrow.
  • listbox: the wrapper container for options and groups.
  • <optgroup>: groups related options together with a label inside the listbox.
  • <option>: an individual selectable value within the menu.

You can style these parts directly via CSS, targeting them like any other selector:

selectmenu::part(listbox) {
  /* add listbox styles */
}

However, a more flexible route is to slot your own markup directly into the element. With Shadow DOM slots, you can replace any part of <selectmenu> with entirely custom HTML. The browser uses your markup in place of its default structure, giving you a blank canvas for the dropdown. Here's an abbreviated example of how that looks:

<selectmenu class="my-custom-select">
  <div slot="button">
    <span behavior="selected-value" slot="selected-value"></span>
    <button behavior="button"></button>
  </div>
  <div slot="listbox">
    <div popover="auto" behavior="listbox">
       <option value="one">one</option>
       <option value="two">two</option>
    </div>
  </div>
</selectmenu>

Using the slot and behavior attributes tells the browser how to handle interaction and keyboard navigation. This approach can provide accessibility out of the box, since the browser applies its built-in semantics to whatever markup you slot in.

For our radial menu, we'll rely on this slotted markup strategy. The markup itself follows the pattern from the <selectmenu> explainer, adapted for our purposes:

<selectmenu class="selectmenu">
  <button class="selected-button" slot="button" behavior="button">
    <span behavior="selected-value" class="selected-value"></span>
  </button>
  <div slot="listbox">
    <div popover behavior="listbox">
      <option value="one">one</option>
      <option value="two">two</option>
      <option value="three">three</option>
      <option value="four">four</option>
      <option value="five">five</option>
      <option value="six">six</option>
    </div>
  </div>
</selectmenu>

Notice that the selected-value behavior is defined inside the button, so the button always displays the currently chosen option. The listbox draws on the Popover API for its open and close state. When testing in Chrome Canary, the basic interactions and keyboard navigation already work without extra JavaScript.

The inherent limitation of a radial menu is that a selectmenu is a two-dimensional element in terms of its parts structure. To achieve the curved, circular layout where options orbit the trigger button, we need to abandon the default linear arrangement of a typical listbox.

Instead of relying on the listbox as a fixed container with a single top-down flow, we can position options absolutely relative to a custom circle container. A circle is simply a container with the right dimensions and border-radius. The approach requires anchoring our options around a center point — hence the CSS Anchor Positioning API.

By applying anchor-name to a central reference element and then using the position-anchor property on each option, we can pin those options to the circle's circumference. The angle for each option is set inline, which lets us distribute them evenly around the ring without any JavaScript-driven calculations. Once anchored, we rotate each option so its label points outward from the center, making the menu more legible.

One thing to keep in mind is that the dimensional styling of selectmenu slots follows the standard box model. So we need to ensure our custom container properly inherits the intended size — dimensions set explicitly on the listbox slot won't automatically transfer if its content overflows. This is a simple matter of applying width and height explicitly to the physical circle that holds the options.

Additionally, the appearance property may need to be set to none on certain button and listbox parts to clear the default browser styling rules that can override our custom colors, borders, and shadows. This ensures a clean slate for our visual treatment.

After those adjustments, the radial select menu starts to come alive. Hovering an option highlights its intensity, while selecting it updates the button's label through the selected-value piece. The CSS Anchor Positioning API sets the button as the focal center point for the custom listbox ring. Keyboard and click behaviors are handled by Chrome's native selectmenu implementation under the hood.

A few caveats to this exploratory work: because <selectmenu> is still under active design within the Open UI group, its API and supported parts may evolve. The behavior attributes we slot in today might change or be renamed. The Anchor Positioning API is still an Editor's Draft, and its property names may shift as well. The Popover API also has its own showPopover(), hidePopover(), and togglePopover() methods that we haven't needed since the selectmenu automatically opens and closes our listbox. As these features stabilize, this approach — styling form controls without the old hacks and JavaScript workarounds — will only get more robust.

Styling the Menu Shell

Before we can arrange the options into a radial layout, we need a styled button to anchor everything to. Custom properties on :root keep the color scheme and sizing in one place, and a minimal reset via box-sizing: border-box sets a consistent foundation. Flexbox then centers the control on the page:

/* Color variables */
:root {
  --color-pink: #FFD5FF;
  --color-violet: #B47EB3;
  --color-tiffany: #92D1C3;
  --color-beige: #EEF5DB;
  --color-tea: #C7EFCF;
  --color-lightest: #F0EFF4;
  --color-darkest: #333745;
}

/* Reset sizing */
*, *::before, *::after {
  box-sizing: border-box;
}

/* Center the menu */
body {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 100vh;
  margin: 0;
  padding: 0;
  padding: 5vh 5vw;
  background: var(--color-darkest);
}

Three size variables drive the geometry of the widget:

  • --orb-size — the button’s diameter.
  • --option-size — the diameter of each option.
  • --circle-size — the overall diameter of the radial menu when open.
:root {
  /* Color variables */
  --color-pink: #FFD5FF;
  --color-violet: #B47EB3;
  --color-tiffany: #92D1C3;
  --color-beige: #EEF5DB;
  --color-tea: #C7EFCF;
  --color-lightest: #F0EFF4;
  --color-darkest: #333745;
  
  /* Sizing variables */
  --orb-size: 110px;
  --option-size: 100px;
  --circle-size: 320px;
}

The button itself becomes a perfect circle by combining a 50% border-radius with the aspect-ratio property. An anchor-name declaration is included now, setting the stage for the Anchor Position API we’ll use shortly. Hover and focus states swap the background color for a touch of feedback:

.selected-button {
  anchor-name: --selectmenu;
  position: relative;
  background: var(--color-beige);
  border: 4px dashed var(--color-violet);
  color: var(--color-darkest);
  width: var(--orb-size);
  aspect-ratio: 1;
  border-radius: 50%;
  cursor: pointer;
  transition: background .2s ease-out;
}

.selected-button:is(:hover, :focus) {
  background: var(--color-tea);
}

Arranging the Options Radially

With the button in place, the options are positioned using absolute positioning relative to the popover container (<div popover behavior="listbox">). No trigonometric CSS functions here — this layout is handled by a handful of custom properties plus some negative margin to center the options:

option {
  --negative-deg: calc(var(--deg) / -1);

  display: flex;
  align-items: center;
  justify-content: center;
  position: absolute;
  top: 50%;
  left: 50%;
  width: var(--option-size);
  height: var(--option-size);
  margin: calc(var(--option-size) / -2);
  color: var(--color-lightest);
  background: var(--color-violet);
  border: 3px dotted var(--color-pink);
  border-radius: 50%;
  cursor: grab;
  transition: all .4s;
}

option:is(:hover, :focus) {
  background-color: var(--color-pink);
  color: var(--color-darkest);
  border-color: var(--color-lemon);
}

The options are sized with the --option-size variable and centered with flexbox. A --negative-deg variable is also declared; it will be used later to compensate for the angular shift that occurs when elements are rotated around a circle.

Because the popover lives in the top layer, outside normal document flow, classic positioning won’t reach it. This is where the Anchor Position API comes in. The button is already tagged with anchor-name: --selectmenu, making it the anchor point. The popover then references that name for its own top and left values. Setting position-fallback to none gives us full manual control:

[popover] {
  position: relative;
  top: anchor(--selectmenu center);
  left: anchor(--selectmenu center);
  position-fallback: none;
  width: fit-content;
  height: fit-content;
  transform: translate(-50%, -50%);
  overflow: visible;
  min-inline-size: var(--circle-size);
  min-block-size: var(--circle-size);
  background: transparent;
  border: none;
}

Spreading Options Around the Circle

With the popover tethered to the button, all options initially stack on top of each other at the center. We now need to distribute them along the circumference, and the math should adapt to however many options are in the list.

Selectmenu layouts
(Large preview)

When the popover is in its popover-open state, a transform chain does the heavy lifting: first rotate each option by a computed angle, then translate it outward by half the circle size, and finally rotate it back by a negative amount to keep the label upright. The order of the transforms is critical.

[popover]:popover-open option {
  /* Half the size of the circle */
  --half-circle: calc(var(--circle-size) / -2);
  
  /* Straighten things up and space them out */
  transform:
      rotate(var(--deg))
      translate(var(--half-circle))
      rotate(var(--negative-deg));
}

A default rotation of 360deg handles the edge case of a single option — a “menu” with one choice still renders neatly at the top of the circle:

[popover] {
  --rotation-divide: calc(180deg / 2);

  /* etc. */
}

A custom --rotation-divide variable on the first option lets us handle that single-item case explicitly:

option:nth-child(1) {
  --deg: var(--rotation-divide);
}
Selectmenu with one option
(Large preview)

For subsequent options, the work is:

  1. Divide the full 360° by the total count of options, then
  2. Multiply that base angle by the index of each option.

The calc() function keeps this all in pure CSS without preprocessors:

[popover]:has(option:nth-child(2)) {
  --rotation-divide: calc(360deg / 2);
}

[popover]:has(option:nth-child(3)) {
  --rotation-divide: calc(360deg / 3);
}

[popover]:has(option:nth-child(4)) {
  --rotation-divide: calc(360deg / 4);
}

[popover]:has(option:nth-child(5)) {
  --rotation-divide: calc(360deg / 5);
}

[popover]:has(option:nth-child(6)) {
  --rotation-divide: calc(360deg / 6);
}

option:nth-child(1) {
  --deg: var(--rotation-divide);
}

option:nth-child(2) {
  --deg: calc(var(--rotation-divide) * 2);
}

option:nth-child(3) {
  --deg: calc(var(--rotation-divide) * 3);
}

option:nth-child(4) {
  --deg: calc(var(--rotation-divide) * 4);
}

option:nth-child(5) {
  --deg: calc(var(--rotation-divide) * 5);
}

option:nth-child(6) {
  --deg: calc(var(--rotation-divide) * 6);
}

/* that’s enough options for you! */
option:nth-child(1n + 7) {
  display: none;
}

See the Pen [Radial selectmenu with Anchoring API - Open UI [forked]](https://codepen.io/smashingmag/pen/MWPPqja) by @utilitybend.

See the Pen Radial selectmenu with Anchoring API - Open UI [forked] by @utilitybend.

Is All That :has() Necessary?

It is unavoidable when sticking to plain CSS. The selectors must know how many children exist to compute the correct angles. JavaScript can simplify this considerably. If the popover element gets an ID, we can count its children and pass that figure along as a custom property on the selectmenu itself:

const optionAmount = document.getElementById('popoverlistbox').childElementCount;
popoverlistbox.style.setProperty('--children', optionAmount);

That change condenses the many :has() overrides into a single set of styles driven by that new --children variable:

option {
  --rotation-divide: calc(360deg / var(--children));
  --negative: calc(var(--deg) / -1);
}

Even with scripting in play, capping the option count at 6 is a good rule of thumb. Beyond that, the circle becomes crowded and requires additional layout tweaks:

See the Pen [Radial selectmenu Open UI with JS children count [forked]](https://codepen.io/smashingmag/pen/vYVVzyj) by @utilitybend.

See the Pen Radial selectmenu Open UI with JS children count [forked] by @utilitybend.

Animating the Expansion

Native support for animating popovers is still in the pipeline. The technique below is a stop-gap and will not be best practice once the real API ships. Treat it as a transitional enhancement.

Start by forcing the popover to always maintain a display: block and establish its own stacking context:

[popover] {
  display: block;
  position: absolute;
  /* etc. */
}

This removes the automatic top-layer placement. You must now handle the z-index explicitly, which is one of the problems popovers were designed to solve. For this demo, the button gets a z-index to keep it above the expanded list:

.selected-button {
  z-index: 2;
  /* etc. */
}

Finally, the :not() pseudo-class resets the transform when the popover is closed so the options can animate back to their hidden state:

[popover]:not(:popover-open) {
  z-index: -1;
}

[popover]:not(:popover-open) option {
  transform: rotate(var(--deg)) translate(0) rotate(var(--negative-deg));
}

See the Pen [Radial selectmenu with Anchoring API and animation [forked]](https://codepen.io/smashingmag/pen/abRRaWW) by @utilitybend.

See the Pen Radial selectmenu with Anchoring API and animation [forked] by @utilitybend.

Adding Images to Option Values

The base Selectmenu specification deliberately doesn’t accept innerHTML for the selected value — among other concerns, it could lead to duplicated IDs. That doesn’t stop us from building a richer widget, though. This next demo uses the exact same radial layout and turns the control into a potion-selector for a role-playing game aesthetic, simply by adding images to the option content:

See the Pen [Open-UI - Select a potion (Chrome Canary) [forked]](https://codepen.io/smashingmag/pen/XWxxPgN) by @utilitybend.

See the Pen Open-UI - Select a potion (Chrome Canary) [forked] by @utilitybend.

A tiny scripting layer, added purely as an optional enhancement, transfers the selected option’s innerHTML into the button’s value container:

const selectMenus = document.querySelectorAll("selectmenu");
selectMenus.forEach((menu) => {
  const selectedvalue = menu.querySelector(".selected-value");
  selectedvalue.innerHTML = menu.selectedOption.innerHTML;
  menu.addEventListener("change", () => {
    selectedvalue.innerHTML = menu.selectedOption.innerHTML;
  });
});

Where This Leaves Us

The Selectmenu element, CSS Anchor Position API, and improved popover support are all still being drafted. Even so, the demos here are functional enough to show their traction. The major upside of these built-in browser features is that they standardize behaviors we now rely on heavy JavaScript libraries to produce: custom styled selects, anchored tooltips, and dialogs. Less code to ship, more consistent behavior to use. The future is legitimately lighter-load for users and more precise control for developers working in the browser.