Customizable select elements
This article explains how to use dedicated, modern HTML and CSS features together to create fully-customized <select>
elements. This includes having full control over styling the select button, drop-down picker, arrow icon, current selection checkmark, and each individual <option>
element.
Background
Traditionally it has been difficult to customize the look and feel of <select>
elements because they contain internals that are styled at the operating system level, which can't be targeted using CSS. This includes the drop-down picker, arrow icon, and so on.
Previously, the best available option — aside from using a custom JavaScript library — was to set an appearance
value of none
on the <select>
element to strip away some of the OS-level styling, and then use CSS to customize the bits that can be styled. This technique is explained in Advanced form styling.
Customizable <select>
elements provide a solution to these issues. They allow you to build examples like the following, using only HTML and CSS, which are fully customized in supporting browsers. This includes <select>
and drop-down picker layout, color scheme, icons, font, transitions, positioning, markers to indicate the selected icon, and more.
In addition, they provide a progressive enhancement on top of existing functionality, falling back to "classic" selects in non-supporting browsers.
You'll find out how to build this example in the sections below.
What features comprise a customizable select?
You can build customizable <select>
elements using the following HTML and CSS features:
- Plain old
<select>
,<option>
, and<optgroup>
elements. These work just the same as in "classic" selects, except that they have additional permitted content types. - A
<button>
element included as the first child inside the<select>
element, which wasn't previously allowed in "classic" selects. When this is included, it replaces the default "button" rendering of the closed<select>
element. This is commonly known as the select button (as it is the button you need to press to open the drop-down picker).Note: The select button is inert by default so that if interactive children (for example, links or buttons) are included inside it, it will still be treated like a single button for interaction purposes — for example, the child items won't be focusable or clickable.
- The
<selectedcontent>
element can optionally be included inside the<select>
element's first child<button>
element in order to display the currently selected value inside the closed<select>
element. This contains a clone of the currently-selected<option>
element's content (created usingcloneNode()
under the hood). - The
::picker(select)
pseudo-element, which targets the entire contents of the picker. This includes all elements inside the<select>
element, except the first child<button>
. - The
appearance
property valuebase-select
, which opts the<select>
element and the::picker(select)
pseudo-element into the browser-defined default styles and behavior for customizable select. - The
:open
pseudo-class, which targets the select button when the picker (::picker(select)
) is open. - The
::picker-icon
pseudo-element, which targets the icon inside the select button — the arrow that points down when the select is closed. - The
:checked
pseudo-class, which targets the currently-selected<option>
element. - The
::checkmark
pseudo-element, which targets the checkmark placed inside the currently-selected<option>
element to provide a visual indication of which one is selected.
In addition, the <select>
element and its drop-down picker have the following behavior assigned to them automatically:
- They have an invoker/popover relationship, as specified by the Popover API, which provides the ability to select the picker when open via the
:popover-open
pseudo-class. See Using the Popover API for more details of popover behavior. - They have an implicit anchor reference, meaning that the picker is automatically associated with the
<select>
element via CSS anchor positioning. The browser default styles position the picker relative to the button (the anchor) and you can customize this position as explained in Positioning elements relative to their anchor. The browser default styles also define some position-try fallbacks that reposition the picker if it is in danger of overflowing the viewport. Position try fallback are explained in Handling overflow: try fallbacks and conditional hiding.
Note:
You can check browser support for customizable <select>
by viewing the browser compatibility tables on the reference pages for related features such as <selectedcontent>
, ::picker(select)
, and ::checkmark
.
Let's look at all of the above features in action, by walking through the example shown at the top of the page.
Customizable select markup
Our example is a typical <select>
menu that allows you to choose a pet. The markup is as follows:
<form>
<p>
<label for="pet-select">Select pet:</label>
<select id="pet-select">
<button>
<selectedcontent></selectedcontent>
</button>
<option value="">Please select a pet</option>
<option value="cat">
<span class="icon" aria-hidden="true">🐱</span
><span class="option-label">Cat</span>
</option>
<option value="dog">
<span class="icon" aria-hidden="true">🐶</span
><span class="option-label">Dog</span>
</option>
<option value="hamster">
<span class="icon" aria-hidden="true">🐹</span
><span class="option-label">Hamster</span>
</option>
<option value="chicken">
<span class="icon" aria-hidden="true">🐔</span
><span class="option-label">Chicken</span>
</option>
<option value="fish">
<span class="icon" aria-hidden="true">🐟</span
><span class="option-label">Fish</span>
</option>
<option value="snake">
<span class="icon" aria-hidden="true">🐍</span
><span class="option-label">Snake</span>
</option>
</select>
</p>
</form>
Note:
The aria-hidden="true"
attribute is included on the icons so that they will be hidden from assistive technologies, avoiding the option values being announced twice (for example, "cat cat").
The example markup is nearly the same as "classic" <select>
markup, with the following differences:
-
The
<button><selectedcontent></selectedcontent></button>
structure represents the select<button>
. Adding the<selectedcontent>
element causes the browser to clone the currently-selected<option>
inside the button, which you can then provide custom styles for. If this structure is not included in your markup, the browser will fall back to rendering the selected option's text inside the default button, and you won't be able to style it as easily.Note: You can include arbitrary content inside the
<button>
to render whatever you want inside the closed<select>
, but be careful when doing this. What you include can alter the accessible value exposed to assistive technology for the<select>
element. -
The rest of the
<select>
contents represents the drop-down picker, which is usually limited to the<option>
elements representing the different choices in the picker. You can include other content in the picker, but it is not recommended. -
Traditionally,
<option>
elements could only contain text, but in a customizable select you can include other markup structures like images, other non-interactive text-level semantic elements, and more. You can even use the::before
and::after
pseudo-elements to include other content, although bear in mind that this wouldn't be included in the submittable value. In our example, each<option>
contains two<span>
elements containing an icon and a text label respectively, allowing each one to be styled and positioned independently.Note: Because the
<option>
content can contain multi-level DOM sub-trees, not just text nodes, there are rules concerning how the browser should extract the current<select>
value via JavaScript. The selected<option>
element'stextContent
property value is retrieved,trim()
is run on it, and the result is set as the<select>
value.
This design allows non-supporting browsers to fall back to a classic <select>
experience. The <button><selectedcontent></selectedcontent></button>
structure will be ignored completely, and the non-text <option>
contents will be stripped out to just leave the text node contents, but the result will still function.
Opting in to the custom select rendering
To opt-in to the custom select functionality and minimal browser base styles (and remove the OS-provided styling), your <select>
element and its drop-down picker (represented by the ::picker(select)
pseudo-element) both need to have an appearance
value of base-select
set on them:
select,
::picker(select) {
appearance: base-select;
}
You can choose to opt-in just the <select>
element to the new functionality, leaving the picker with the default OS styling, but in most cases, you'll want to opt-in both. You can't opt-in the picker without opting in the <select>
element.
Once this is done, the result is a very plain rendering of a <select>
element:
You are now free to style this in any way you want. To begin with, the <select>
element has custom border
, background
(which changes on :hover
or :focus
), and padding
values set, plus a transition
so that the background change animates smoothly:
select {
border: 2px solid #ddd;
background: #eee;
padding: 10px;
transition: 0.4s;
}
select:hover,
select:focus {
background: #ddd;
}
Styling the picker icon
To style the icon inside the select button — the arrow that points down when the select is closed — you can target it with the ::picker-icon
pseudo-element. The following code gives the icon a custom color
and a transition
so that changes to its rotate
property are smoothly animated:
select::picker-icon {
color: #999;
transition: 0.4s rotate;
}
Next up, ::picker-icon
is combined with the :open
pseudo-class — which targets the select button only when the drop-down picker is open — to give the icon a rotate
value of 180deg
when the <select>
is opened.
select:open::picker-icon {
rotate: 180deg;
}
Let's have a look at the work so far — note how the picker arrow rotates smoothly through 180 degrees when the <select>
opens and closes:
Styling the drop-down picker
The drop-down picker can be targeted using the ::picker(select)
pseudo-element. As mentioned earlier, the picker contains everything inside the <select>
element that isn't the button and the <selectedcontent>
. In our example, this means all the <option>
elements and their contents.
First of all, the picker's default black border
is removed:
::picker(select) {
border: none;
}
Now the <option>
elements are styled. They are laid out with flexbox, aligning them all to the start of the flex container and including a 20px
gap
between each one. Each <option>
is also given the same border
, background
, padding
, and transition
as the <select>
, to provide a consistent look and feel:
option {
display: flex;
justify-content: flex-start;
gap: 20px;
border: 2px solid #ddd;
background: #eee;
padding: 10px;
transition: 0.4s;
}
Note:
Customizable <select>
element <option>
s have display: flex
set on them by default, but it is included in our stylesheet anyway to clarify what is going on.
Next, a combination of the :first-of-type
, :last-of-type
, and :not()
pseudo-classes is used to set an appropriate border-radius
on the top and bottom corners of the picker, and remove the border-bottom
from all <option>
elements except the last one so the borders don't look messy and doubled-up:
option:first-of-type {
border-radius: 8px 8px 0 0;
}
option:last-of-type {
border-radius: 0 0 8px 8px;
}
option:not(option:last-of-type) {
border-bottom: none;
}
Next a different background
color is set on the odd-numbered <option>
elements using :nth-of-type(odd)
to implement zebra-striping, and a different background
color is set on the <option>
elements on focus and hover, to provide a useful visual highlight during selection:
option:nth-of-type(odd) {
background: #fff;
}
option:hover,
option:focus {
background: plum;
}
Finally for this section, a larger font-size
is set on the <option>
icons (contained within <span>
elements with a class of icon
) to make them bigger, and the text-box
property is used to remove some of the annoying spacing at the block-start and block-end edges of the icon emojis, making them align better with the text labels:
option .icon {
font-size: 1.6rem;
text-box: trim-both cap alphabetic;
}
Our example now renders like this:
Adjusting the styling of the selected option contents inside the select button
If you select any pet option from the last few live examples, you'll notice a problem — the pet icons cause the select button to increase in height, which also changes the position of the picker icon, and there is no spacing between the option icon and label.
This can be fixed by hiding the icon when it is contained inside <selectedcontent>
, which represents the contents of the selected <option>
as they appear inside the select button. In our example, it is hidden using display: none
:
selectedcontent .icon {
display: none;
}
This does not affect the styling of the <option>
contents as they appear inside the drop-down picker.
Styling the currently selected option
To style the currently selected <option>
as it appears inside the drop-down picker, you can target it using the :checked
pseudo-class. This is used to set the selected <option>
element's font-weight
to bold
:
option:checked {
font-weight: bold;
}
Styling the current selection checkmark
You've probably noticed that when you open the picker to make a selection, the currently selected <option>
has a checkmark at its inline-start end. This checkmark can be targeted using the ::checkmark
pseudo-element. For example, you might want to hide this checkmark (for example, via display: none
).
You could also choose to do something a bit more interesting with it — earlier on the <option>
elements were laid out horizontally using flexbox, with the flex items being aligned to the start of the row. In the below rule, the checkmark is moved from the start of the row to the end by setting an order
value on it greater than 0
, and aligning it to the end of the row using an auto
margin-left
value (see Alignment and auto margins).
Finally, the value of the content
property is set to a different emoji, to set a different icon to display.
option::checkmark {
order: 1;
margin-left: auto;
content: "☑️";
}
Note:
The ::checkmark
and ::picker-icon
pseudo-elements are not included in the accessibility tree, so any generated content
set on them will not be announced by assistive technologies. You should still make sure that any new icon you set visually makes sense for its intended purpose.
Let's check in again on how the example is rendering. The updated state after the last three sections is as follows:
Animating the picker using popover states
The customizable <select>
element's select button
and drop-down picker are automatically given an invoker/popover relationship, as described in Using the Popover API. There are many advantages that this brings to <select>
elements; our example takes advantage of the ability to animate between popover hidden and showing states using transitions. The :popover-open
pseudo-class represents popovers in the showing state.
The technique is covered quickly in this section — read Animating popovers for a more detailed description.
First of all, the picker is selected using ::picker(select)
, and given an opacity
value of 0
and a transition
value of all 0.4s allow-discrete
. This causes all properties that change value when the popover state changes from hidden to showing to animate.
::picker(select) {
opacity: 0;
transition: all 0.4s allow-discrete;
}
The list of transitioned properties features opacity
, however it also includes two discrete properties whose values are set by the browser default styles:
display
-
The
display
values changes fromnone
toblock
when the popover changes state from hidden to shown. This needs to be animated to ensure that other transitions are visible. overlay
-
The
overlay
value changes fromnone
toauto
when the popover changes state from hidden to shown, to promote it to the top layer, then back again when it is hidden to remove it. This needs to be animated to ensure the removal of the popover from the top layer is deferred until the transition completes, ensuring the transition is visible.
Note:
The allow-discrete
value is needed to enable discrete property animations.
Next, the picker is selected in the showing state using ::picker(select):popover-open
and given an opacity
value to 1
— this is the end state of the transition:
::picker(select):popover-open {
opacity: 1;
}
Finally, because the picker is being transitioned while it is moving from display: none
to a display
value that makes it visible, the transition's starting state has to be specified inside a @starting-style
block:
@starting-style {
::picker(select):popover-open {
opacity: 0;
}
}
These rules work together to make the picker smoothly fade in and fade out when the <select>
is opened and closed.
Positioning the picker using anchor positioning
A customizable <select>
element's select button and drop-down picker have have an implicit anchor reference, and the picker is automatically associated with the select button via CSS anchor positioning. This means that an explicit association does not need to be made using the anchor-name
and position-anchor
properties.
In addition, the browser's default styles provide a default position, which you can customize as explained in Positioning elements relative to their anchor.
In our demo, the position of the picker is set relative to its anchor by using the anchor()
function inside its top
and left
property values:
::picker(select) {
top: calc(anchor(bottom) + 1px);
left: anchor(10%);
}
This results in the top edge of the picker always being positioned 1 pixel down from the bottom edge of the select button, and the left edge of the picker always being positioned 10%
of the select button's width across from its left edge.
Final result
After the last two sections, the final updated state of our <select>
is rendered like this:
Customizing other classic select features
The above sections have covered all the new functionality available in customizable selects, and shown how it interacts with both classic single-line selects, and related modern features such as popovers and anchor positioning. There are some other <select>
element features not mentioned above; this section talks about how they currently work alongside customizable selects:
<select multiple>
-
There isn't currently any support specified for the
multiple
attribute on customizable<select>
elements, but this will be worked on in the future. <optgroup>
-
The default styling of
<optgroup>
elements is the same as in classic<select>
elements — bolded and indented less than the contained options. You need to make sure to style the<optgroup>
elements so they fit into the overall design, and bear in mind that they will behave just as containers are expected to behave in conventional HTML. In customizable<select>
elements, the<legend>
element is allowed as a child of<optgroup>
, to provide a label that is easy to target and style. This replaces any text set in the<optgroup>
element'slabel
attribute, and it has the same semantics.
Next up
In the next article of this module, we will explore the different UI pseudo-classes available to us in modern browsers for styling forms in different states.