Customizable select listboxes

This article follows on from the previous one, looking at how to style customizable listbox <select> elements.

One of the major advantages of customizable <select> listboxes over "classic" select listboxes is that you can fully style all parts of the control, and you can include a much wider variety of child elements inside them, which means greater flexibility in terms of design and functionality.

When we talk about "dropdown" <select> elements, we are talking about controls featuring a select button that, when pressed, shows a dropdown picker from which you can select an option. These are specified using basic HTML such as <select>.

"Listbox" <select> elements on the other hand are controls featuring a box that shows multiple options at once, from which you can select one or multiple options. You opt into rendering a "listbox" select by specifying the multiple attribute (to allow multiple selections) and/or a size value of more than 1. For example, <select multiple> or <select size="3">.

The following live example illustrates the difference:

Note: The multiple attribute, as well as any size value greater than 1, opts the <select> element into listbox mode.

How do customizable listboxes compared to customizable dropdowns?

A customizable listbox <select> is easier to style than the dropdown variant:

  • There is no dropdown picker, so you don't need to worry about styling it with the ::picker(select) pseudo-element or its :open and closed states.
  • You don't need to worry about styling the select button's icon using ::picker-icon, or manipulating how the currently selected <option> is displayed inside the button using the <selectedcontent> element.
  • There is only a single container involved; you don't need to worry about the position of the picker relative to the button.

A basic customized listbox

Let's walk through a basic example to show how a customized listbox is implemented. The markup for this example looks like so:

html
<p>
  <label for="pet-select">Select pets:</label><br />
  <select id="pet-select" multiple>
    <option value="cat">Cat</option>
    <option value="dog">Dog</option>
    <option value="chicken">Chicken</option>
    <option value="fish">Fish</option>
    <option value="Hamster">Hamster</option>
  </select>
</p>

There is nothing remarkable here. Note that we are rendering our listbox using <select multiple> rather than <select size="3">. The only difference is that we can select multiple options rather than a single option. The styling works in exactly the same way.

We begin our styling by opting the <select> into custom styling with an appearance value of base-select:

css
select {
  appearance: base-select;
}

This enables us to style our <select> and <option> elements however we want. Our basic styles look like this:

css
select {
  border: 2px solid #ddd;
  border-radius: 8px;
  background: #eee;
  width: 200px;
  height: 130px;
}

option {
  background: #eee;
  padding: 10px;
  height: 40px;
  outline: none;
}

option:nth-of-type(odd) {
  background: #fff;
}

Next, we set an order value of 1 on the ::checkmark pseudo-element to make the checkmark for selected options appear on the right rather than the left, and set a custom checkmark icon using the content property.

css
option::checkmark {
  order: 1;
  margin-left: auto;
  content: "☑️";
}

Finally, we set a bold font-weight on :checked options, and a custom background color for option :hover and :focus states so that you always know which option you have hovered or focused.

css
option:checked {
  font-weight: bold;
}

option:hover,
option:focus {
  background: plum;
}

This example renders like so:

Listbox style variations

Because customized listboxes are just standard HTML elements, you can style them however you want. In this section we show you a couple of variations on the previous example. They both use the same or similar markup; we've added a bit of extra CSS to significantly change the look and feel.

Expanding listbox

In this example, we present the listbox at the height of a single option by default, hiding the overflow this creates, and adding a transition to smoothly animate the <select> height when its state changes. We also set an interpolate-size value of allow-keywords to opt the browser in to animating between lengths and keywords.

css
select {
  height: 44px;
  overflow: hidden;
  transition: 0.6s height;
  interpolate-size: allow-keywords;
}

We change the height to fit-content when the <select> is hovered or focused so that it expands to its full height. Note that when you tab into a customized select, the first <option> receives the focus rather than the <select> itself. As a result, we had to use select:has(option:focus) to select the <select> when an <option> is focused, rather than just select:focus.

css
select:hover,
select:has(option:focus) {
  height: fit-content;
}

The example now renders like this:

Horizontal listbox

In this example, we present the listbox options horizontally rather than vertically.

The HTML is the same as the previous examples, except that we've included an extra wrapper <div> to allow us to set a width on the <select> and then a different width on the wrapper so that all the <option> elements can be kept on one line and scrolled when the <select> becomes too narrow to fit them all.

html
<p>
  <label for="pet-select">Select pets:</label><br />
  <select id="pet-select" multiple>
    <div class="wrapper">
      <option value="cat">Cat</option>
      <option value="dog">Dog</option>
      <option value="chicken">Chicken</option>
      <option value="fish">Fish</option>
      <option value="hamster">Hamster</option>
      <option value="gerbil">Gerbil</option>
      <option value="guinea">Guinea pig</option>
    </div>
  </select>
</p>

In the CSS, we start by setting the containing <p> element's width and margin so that the demo will be centered horizontally in the viewport and take up most of the width. We then size the <select> to take up the full width of its parent and only be as tall as the <option> elements. The .wrapper <div> is given a display value of flex, causing the <option> elements to be laid out horizontally in a row; we then set its width so that it is always as wide as the <option> elements.

css
p {
  width: 90%;
  margin: 0 auto;
}

select {
  width: 100%;
  height: fit-content;
}

.wrapper {
  display: flex;
  width: fit-content;
}

Next, we give the <option> elements some extra padding to space them out horizontally, and a position value of relative so we can position their descendants relative to them.

css
option {
  padding: 10px 30px;
  position: relative;
}

Finally, we absolutely position the option checkmarks and give them a custom look.

css
option::checkmark {
  position: absolute;
  top: -2px;
  left: 2px;
  font-size: 1.5rem;
  color: red;
  text-shadow: 1px 1px 1px black;
}

Our second variation renders like this:

A more complex listbox

In this section we'll walk through a more complex example, which provides a contact picker listbox with a built-in filter field and a link to access a (fictional) contact editing mode.

HTML

In the markup, we include a <form> that contains a heading and a wrapper <div>. Inside the wrapper, we include three more <div> elements that respectively contain a text <input> representing our filter field, a listbox <select>, and a link. The <select> will be populated with <option> elements representing our contact choices via JavaScript.

html
<form>
  <h2>Contact select</h2>
  <div class="wrapper">
    <div class="filter">
      <input
        type="text"
        aria-label="Filter contacts"
        placeholder="Filter by name, e.g. amara" />
    </div>
    <div class="options">
      <select
        multiple
        name="contact-select"
        aria-label="Select contacts"></select>
    </div>
    <div class="edit">
      <a href="#">Edit contacts</a>
    </div>
  </div>
</form>

CSS

We start our CSS by opting the <select> element into custom styling, as before:

css
select {
  appearance: base-select;
}

Most of the styling is fairly rudimentary, but we'll run through it, pointing out anything significant along the way. First of all, we style the .wrapper <div>, giving it a fixed width that controls the horizontal sizing of the entire control.

css
.wrapper {
  border: 2px solid #ddd;
  border-radius: 8px;
  background: #ddd;
  width: 250px;
}

Next, we style the filter <input>, the .options <div> and the contained <select>, and the .edit <div> containing the link. Most notably, we give the <select> a fixed height and an overflow-y value of scroll so that the contained <option> elements will scroll inside it.

css
.filter input {
  display: block;
  padding: 5px;
  border-radius: 5px;
  border: 1px solid #bbb;
  width: 95%;
  margin: 8px auto;
}

.options {
  padding: 0 5px;
  background: #ddd;
}

select {
  height: 200px;
  overflow-y: scroll;
  width: 100%;
  border: 1px solid #bbb;
}

.edit {
  height: 36px;
  display: flex;
  align-items: center;
  justify-content: center;
}

We style our <option> elements in a similar manner to earlier examples, giving them zebra-striping, and clear :hover and :focus styles:

css
option {
  background: #eee;
  padding: 10px;
}

option:nth-of-type(odd) {
  background: #fff;
}

option:checked {
  font-weight: bold;
}

option:hover,
option:focus {
  background: plum;
}

Our next step is to get rid of the default focus outline for the <input>, <option>, and <a> elements. We already provided alternative styling for the <option> elements in the previous code block; here, we provide more subtle alternatives for the <input> and <a> elements.

css
input,
option,
a {
  outline: none;
}

input:hover,
input:focus {
  border: 1px solid #999;
  background: #eef;
}

.edit a {
  color: #333;
}

a:hover,
a:focus {
  outline: 2px dotted #666;
}

Finally, we provide custom styling for the checkmarks of selected options via the ::checkmark pseudo-element:

css
option::checkmark {
  order: 1;
  margin-left: auto;
  content: "☑️";
}

JavaScript

The last addition our example needs is some JavaScript to power the option populating and filtering functionality.

In a real site you will probably pull in an up-to-date contacts list from a server, but in this case we've provided the data in a static contacts object (we've hidden most of the contacts for brevity). For each contact, we store a name and a boolean indicating if they were selected in the <select> element.

js
const contacts = [
  { name: "Aisha Khan", selected: false },
  ...
];

We start by grabbing references to our .filter <input> and <select> elements:

js
const filterInput = document.querySelector(".filter input");
const select = document.querySelector("select");

Next, we define a function called populateOptions(), which takes an array of objects as a parameter. Inside the function we first empty the contents of the <select> element. We then loop through the input array and create an <option> element for each object in the array, setting its textContent and selected properties to equal the object's name and selected properties. Each <option> element is appended to the DOM as a child of the <select>.

js
function populateOptions(array) {
  select.innerHTML = "";

  array.forEach((obj) => {
    const option = document.createElement("option");
    option.textContent = obj.name;
    option.selected = obj.selected;
    select.appendChild(option);
  });
}

Now we define another function, filterOptions(), which takes a filter string and an array of objects as parameters. We check if the string is equal to the empty string or one or more spaces by comparing the return value of its trim() method to "". If this returns true, we run the populateOptions() function, passing it the full array so that the <select> is populated with all <option> elements. If it returns false, we filter the input array using its filter() method to include only objects whose name property startsWith() the filter string, then we pass the filtered array to the populateOptions() function so that the <select> is populated with a filtered set of <option> elements.

js
function filterOptions(filter, array) {
  if (filter.trim() === "") {
    populateOptions(array);
  } else {
    const filteredArray = array.filter((obj) =>
      obj.name.toLowerCase().startsWith(filter.toLowerCase()),
    );
    populateOptions(filteredArray);
  }
}

Note: We convert both the object name and the filter string to lowercase using toLowerCase() so that the filter matching is case-insensitive.

Next, we add an input event listener to the .filter <input> element so that when its value is edited, it runs the filterOptions() function to filter the displayed <option> elements. We pass it the <input>'s current value as the filter string, and the contacts array as the input array.

js
filterInput.addEventListener("input", () => {
  filterOptions(filterInput.value, contacts);
});

The next bit of code adds a change event listener to the <select> element so that every time an <option> is selected or deselected, the selected status of the objects in the contacts array is synchronized with the selected status of the currently displayed <option> objects. This is required because every time we apply a new filter to our <select> element, the displayed <option> elements are freshly generated from the contacts array, which includes their selected state. If we didn't do this, we would lose our selected options each time we changed the filter.

There is no way to detect exactly which <option> has been changed each time one is toggled, so we have solved the problem like this:

  1. Get an array of all the currently displayed <option> values by creating an array from the select.options collection using Array.from, then mapping it using its map() method to replace each <option> in the array with its value.
  2. Get an array of all the currently selected <option> values using the same methodology, except that this time we create the input array from the select.selectedOptions collection.
  3. For each contact object in the contacts array, check whether the contact name property value is included in the allCurrentValues array using the includes() method. If not, ignore it, so that we don't end up toggling the selected status of the contacts that aren't even displayed. If so, set the contact selected property to the result of checking whether the currentSelectedValues array includes() the contact name — if this is the case, set the object property to true, or false otherwise.
js
select.addEventListener("change", () => {
  const allCurrentValues = Array.from(select.options).map(
    (option) => option.value,
  );
  const currentSelectedValues = Array.from(select.selectedOptions).map(
    (option) => option.value,
  );

  contacts.forEach((contact) => {
    if (allCurrentValues.includes(contact.name)) {
      contact.selected = currentSelectedValues.includes(contact.name);
    }
  });
});

Finally, we run the populateOptions() function, passing it the contacts array, so that on page load the full list of contacts is displayed.

js
populateOptions(contacts);

Result

The example renders like so:

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.

See also