Advanced Form Control Styling With Selectmenu And Anchoring API

Advanced Form Control Styling With Selectmenu And Anchoring API

No doubt you’ve had to style a <select> menu before. And when you do, you often have had to reach far down in your CSS arsenal of tricks or rely on JavaScript to get anything near the level of customization you want. It’s a long-running headache in the front-end world.

Well, thanks to the efforts of the Open UI community, we have a new <selectmenu> element to look forward to, and its purpose is to provide CSS styling affordances to selection menus in ways we’ve never had before.

We’re going to demonstrate an initial implementation of <selectmenu> in this article. But we’ll throw in a couple of twists while we’re at it. What we’re making is a radial select menu, something we could never have done with CSS alone. And since we're working with experimental tech, we’re going to toss in more experimental features along the way, including images, the HTML Popover API, and the CSS Anchor Positioning API. The result is going to wind up like this:

  • <selectmenu>: This is the selector itself. It holds the button and listbox of menu options.
  • button: This part toggles the visibility of the listbox between open and close.
  • selected-value: This displays the value of the menu option that is currently selected. So, if you have a listbox with three options and the second option is selected, the second option is what matches the part.
  • marker: Dropdown menus usually have some sort of downward-facing arrow icon to indicate that the menu can be expanded. This is that part of the menu.
  • listbox: This is the wrapper that contains the options and any <optgroup> elements that group certain options together inside the listbox.
  • <optgroup>: We already let the cat out of the bag on this one, but this part groups options together. It includes a label for the group.
  • <option>: A value that the user is able to select in the menu. There can be one, but it’s much more common to see a <select> — and, by extension — a <selectmenu> with multiple options.

The other way is to slot the content ourselves in HTML. This can be a nice approach since it allows us to customize the markup any way we like. In other words, we can replace any of the parts we want, and the browser will use our markup instead of the implicit structure. In fact, this is the approach we’ll use in the radial menu we’re making.

The way to replace parts in the HTML is to use the slots. The markup we use for a slot lives in a separate tree in the Shadow DOM, replacing the contents of the DOM with what we specify in the Shadow DOM.

Here’s an abbreviated example in HTML. Notice how the <button> and listbox are both contained in slots that represent the HTML we want to use for those parts.

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

By using slots and behavior as attributes, we can tell the browser how it should behave and how it should interact with keyboard navigation. If managed carefully, this will also mean that we get good accessibility out of the box because the browser will know how to behave based on what we define.

Ready? OK, let’s start by setting up our markup for our radial <selectmenu>.

The Radial Selectmenu Markup

We will start by creating our own markup for this basic example. We will use pretty much the same approach as used in the explainer of the Selectmenu element because I think it demonstrates the vast flexibility we have to style this element using similar markup.

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

You might notice from the markup that we’ve added the selected-value behavior in the button. This is perfectly fine, as our button will always show the selected value by doing this.

And, just like the example in the explainer, we are using the Popover API inside of our listbox slot. When we look at what we have in Chrome Canary, and see that it already works fine. Take note that even keyboard navigation already seems to be handled for us!

We can add the following formula for our options when the popover is open by adding a transform to our options:

[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));
}

Now, when the popover-open state is triggered, we will rotate each option by a certain number of degrees, translate them along both axes by half the circle size, and rotate it once again by a negative amount of degrees. The order of the transforms is important!

I said we would rotate the options “by a certain number of degrees” because we have to do it for each individual option. This is totally possible in vanilla CSS (and that’s how we’re going to do it), but it could also be done with a Sass loop or even with JavaScript if we needed it.

Let’s add this to our popover style rules:

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

  /* etc. */
}

This will be our default rotation, and it’s a special case for when we only have one option. We’ll use 360deg for the rest in a moment.

For now, we can select the first option and set the --rotation-divide variable on it:

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

Great! Why you would use a select when there is only one option, I don’t know, but nevertheless, it’s handled gracefully:

Styling the other options takes a bit of work because we have to:

  • Divide the circle by the number of available options and
  • Multiply that result for each option.

I’m so glad we have the calc() function in CSS to help us do this. Otherwise, it would be some pretty heavy lifting.

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

Here’s a live demo of what this produces. Remember, Chrome Canary is the only browser that currently supports this, as long as the experimental features flag is enabled.

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

Do We Need All Those :has() Pseudo-Classes?

Yeah, I think so, as long as we’re using plain CSS. And that’s been my goal all along. That said, JavaScript could be useful here.

For example, we could add an ID to the element with the popover attribute and count the children it contains:

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

That way, we can replace all the :has() instances with more concise styles:

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

For this demo, however, you might still want to limit the --children custom property to a maximum of 6. I’ve found that’s the sweet spot before the circle gets too crowded and needs additional tweaks.

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

Let’s Animate This Thing

There are a few more CSS features coming up that will make animating popovers a lot easier. But they’re not ready for us yet, even for this example.

We can get around that with a little trick. But please keep in mind that what we’re about to do will not be the best practice when we get the new animating features. I wanted to give you the information anyway because I think it’s a nice enhancement for what we’re making.

First, let’s add the following to our popover selector:

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

This makes it so our popover will always be displayed block and ready to go wherever it is placed, and we have established a stacking context.

We will lose the benefit of our top layer popover and will have to play around with a z-index to get the effect we want. Juggling z-index values — especially with a large number of items — is never fun. It gets messy fast. That’s one of the ways popovers were designed to help us.

But let’s go ahead and give our button a z-index:

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

Now we can use animations to reveal the options by using the :not() pseudo-class. This is how we can reset the transform when the popover is in its closed state:

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

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

And there you have it! An animated radial <selectmenu>:

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

Let’s Add Some Images While We’re At It

There was quite a bit of discussion about this in the Open UI community, but the selected value does not take innerHTML as an option as, for one, this could result in IDs being duplicated. But I sure do love a good old role-playing game, and I decided to use the <selectmenu> as a potion selector.

This is completely based on everything we just covered, only adding images to demonstrate that it is possible:

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

With a sprinkle of JavaScript (for this totally optional enhancement), we can select the innerHTML of the <selectmenu> element and pass it to our .selected-value button:

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;
  });
});
Conclusion

I don’t know about you, but all of this gets me super excited for the future. Everything we looked at, from the Selectmenu element to the CSS Anchor Position API, is still a work in progress. Still, we can already see the great number of possibilities they will open up for us as designers and developers.

The fact that all of this is coming by way of built-in browser features is what’s most exciting because it gives us a standard way to approach things like customized <select> menus, popovers, and anchoring to the extent that it could eliminate the need for frameworks or libraries that we use today for the same things. We win because we get more control, and users win because they get lighter page loads.

If you’d like to do a bit of research on Selectmenu or even get involved with the Open UI community, you’re more than welcome, as we need more developers to create demos and share their struggles to help make these features better if — and when — they ship.

Further Reading On SmashingMag

  • “Level Up Your CSS Skills With The :has() Selector”, Stephanie Eckles
  • “Creating A High-Contrast Design System With CSS Custom Properties”, Brecht De Ruyte
  • “Write Better CSS By Borrowing Ideas From JavaScript Functions”, Yaphi Berhanu
  • “How To Build A Magazine Layout With CSS Grid Areas”, Jennifer Brehm