Advanced Form Control Styling With Selectmenu And Anchoring API
<selectmenu>
, that will make styling this type of form control a whole lot better. You’re going to walk through an early implementation of this new experimental element by creating a pattern that you would never have thought possible with CSS alone — a radial selection menu.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:
We’ll start with a small bit of context about the work Open UI is doing and the APIs we’re putting to use. From there, we’ll cover the various parts of <selectmenu>
as well as a couple of approaches for styling them. Finally, we’ll build our radial select menu to show just how much flexibility we have to look forward to.
Note: Chrome Canary is best for following along, as it is the only browser that supports what we’re making. Be sure to flip on “Experimental Web Platform Features” by opening chrome://flags
in the browser.
About Open UI
Open UI is a W3C community group that’s trying to give us more and better ways to style and extend native HTML elements and form controls. One of these controls is the <select>
element, and that’s exactly what this article is about.
Except that we’re not actually talking about <select>
, but an offshoot of it called <selectmenu>
. There’s no plan in the works to eliminate <select>
, but rather this proposal is a more customizable version of it. The <select>
element still does its job well, but you’ve no doubt tried to style it before and come to the same conclusion many of us have as well: it’s darn near impossible. MDN nicely sums up the pain points:
The<select>
element is notoriously difficult to style productively with CSS. You can affect certain aspects like any element — for example, manipulating the box model, the displayed font, and so on, and you can use theappearance
property to remove the default systemappearance
.
However, these properties don’t produce a consistent result across browsers, and it is hard to do things like lining different types of form elements up with one another in a column. The<select>
element’s internal structure is complex and hard to control. If you want to get full control, you should consider using a library with good facilities for styling form widgets or try rolling your own dropdown menu using non-semantic elements, JavaScript, and WAI-ARIA to provide semantics.
So, that is where <selectmenu>
comes into the picture. The Open UI group is putting a lot of effort into the parts, states, behaviors, and accessibility to give us more flexibility to style this form of control than we’ve ever had. And they’re approaching this by doing research on a large number of design systems and by carefully mapping out what these systems have in common.
The Experimental Features We’re Using
We’re cobbling together a few experimental technologies for the radial menu we’re going to create, one of which is the <selectmenu>
we’ve touted so far. In addition to that, we’re putting the HTML Popover API and the CSS Anchor Position API to use.
The Popover API comes to us courtesy of the HTML Living Standard. And actually, it originates from the Open UI group, too, as a way to make styling things like alerts easier. So, instead of having to resort to something like the infamous Checkbox Hack in CSS to show and hide content on top of other content, we are getting features for it that are native to the browser. I have another article that dives into this.
The other experimental feature we’re using is the CSS Anchor Position API, a Google-led initiative that is still in Editor’s Draft status in the CSS specification. It essentially “tethers” one element to another in the sense that it allows us to update the size and position of one element based on the size and position of another element. Jhey Thompkins has an excellent write-up of it.
The Parts Of A Selectmenu
We have some options when it comes to styling <selectmenu>
. One of the options is to target the parts of it in CSS. For example, we can style the listbox part like this:
selectmenu::part(listbox) {
/* add listbox styles */
}
There are currently six parts that are available to target in CSS:
<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!
Styling Selectmenu With CSS
Let’s break this down into a few steps. The first thing we’ll need to do is set up some basic styles, then create the menu’s circular shape before tweaking the listbox’s “popping behavior.”
Basic Styles
I added some basic styling to the demo to play around with colors, but feel free to add your own take on it. For simplicity’s sake, I added defined colors as custom properties so you can swap things out. I also used a basic reset by setting box-sizing: border-box
to all the elements. And, finally, my basic styles center the <selectmenu>
inside of the <body>
:
/* 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);
}
Next up is to define the sizes. Let’s use three more custom properties to do that:
--orb-size
: The size of our button,--option-size
: The size of our options,--circle-size
: The size of our radial menu.
These can go right alongside the color variables that are already defined on the :root
:
: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 Circular Shape
I’m going to go ahead and apply the properties we have so far to the button that has the .selected-button
class. This next snippet establishes the circular shape we want for the menu:
.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);
}
I know that dropping a bunch of code in your lap is useless without explaining what it does. So, we have a width
that is set to --orb-size
, and we apply a border-radius
of 50%
on it to round things out. Then, we’re using the aspect-ratio
property to maintain a perfect circle.
What’s up with that anchor-name
? We’ll get to that in a bit, but it’s part of the Anchor Position API that we discussed earlier. I’m merely setting us up for that in a future step.
The rest of the CSS in that snippet adds color and a basic layout. I also trigger a background color change on the :hover
and :focus
states for fun.
Pretty neat so far, right? As you can see, things are still working smoothly after applying these styles.
Styling The Options
Now let’s get those options styled. The idea to get our options into a radial setup is by using absolute positioning on the popover container (<div popover behavior="listbox">
). We will be playing around a bit with custom properties to do this. There are a few ways we could approach this, including the newly released trigonometric functions in CSS. But since we’re already working with a bunch of new features in this article — Selectmenu, Popover API, Anchor Position API, oh my! — I’m going to avoid muddying the water.
Here’s where I landed:
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);
}
Note: I am using the --option-size
variable again, this time to set the width
and height
of the options. The re-usability of variables helps keep things super consistent.
Also in there is a negative margin that is equal to half the width of the options (calc(var(--option-size) / -2)
), or -50px
.
Flexbox is used to place everything in the center. Meanwhile, you may have noticed a new --negative-deg
variable, which we’ll use later to help us position the options around the circular menu. When placing things around a circle, they tend to shift angles, and this variable will help right the ship.
Applying The Anchor Position API
I told you we’d get to this! Just to refresh, this API “tethers” one element to another such that the size and position of one element change with the size and position of another element.
Here’s my implementation:
[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;
}
Because the popover is automatically added to the top layer and because the top layer sits outside of our document flow (we cannot see it when it is inactive), we’re unable to use classic positioning techniques.
That’s where the anchor-name: --selectmenu
declaration I teased earlier comes into play. I had set that on the .selected-button
, which effectively identifies it as an anchored element named --selectmenu
. This is how we anchor the button to the popover. Notice how the top
and left
properties reference --selectmenu
to position the popover.
The CSS snippet also sets position-fallback
to none
, which allows us to take full control of the positioning. If we were to look at our work so far, we would see our options stacked on top of one another right in the center of our button. That might make for a poor user experience, but it is a great starting point for us to position the options around a circle.
Positioning The Options
For this demo, I wanted to have some control over the position of the options depending on the number of available options. For example, if we have six options, they would lay out one way, and if we have three, they lay out another, and so on.
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.
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.
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>
:
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:
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