Design System Breakdown: Select
Issue 13: Part 3 of a deep dive series on specific design system components
Castor’s Select component started with a discussion of scope. Our audit uncovered many types of inputs used for selecting options including: forms, table sorting, filters, multi-selects, country selections, searchable selects, and more.
We split out the needs into the following broad areas:
Select - Select a single option from a list of roughly 4-8 different options, or fewer then 4 when the UI context meant minimal space usage was important.
Combobox - Select a single option from a large list, with the ability to enter text in the box to search for the option you want.
Multiselect - Select multiple options from a list, and manage those options (unselect/delete) in a reasonable way.
We felt trying to put all this functionality into one component would over-complicate the component from both a code and design perspective, so we chose to focus on the first use case: Selecting a single option, and build Combobox and Multiselect later on as separate components, sharing common architecture where it made sense to do so.
We wanted to move quickly and iterate with this component, and we decided the smallest useful thing we could ship that would get people using the component and replacing the legacy select boxes, was a version that had a custom appearance that was in line with our other Castor components, but when selected, used the native options list. This significantly cut down on the amount of interactions we needed to design and consider. We also realised fairly early on that we wanted a native variant like this anyway, to be optionally used on mobile devices where it might be the more familiar UI to people (even though our opinion is that most of these mobile select UIs are pretty poor).
Component anatomy
The basic anatomy of the select box is pretty simple, a border and text label in the same style as our text input, and a chevron icon on the right indicating options are available on click. We used Castor’s 48px tall minimum touch area, double-border internal focus indicator style, and validation states as standard (validation state as shown below is always paired with validation text under the box).
When considering component width, we allowed for a few different options. In the Figma component, width can be dynamic based on the content of the selected item, or static. We provided an easy toggle for this as a variant in Figma, though in code we chose to let integrators specify width via CSS to allow for maximum flexibility.
We offered a borderless option, as we had a lot of these across our products. We only encourage the use of borderless variants where the positioning and context makes the interaction really clear. We also force dynamic width so the chevron is always positioned right next to the text, otherwise there isn’t enough affordance to understand it’s a select. Hover and focus states are all identical to the bordered version.
Options List
The options list is where things get really gnarly. Most standard single option select controls don’t need custom options lists.
Disclaimer: I don’t claim our implementation is perfect, but it’s certainly better than many of the external libraries our engineers had used in the past that we were intending to replace.
Overriding the native options list is something I only recommend if you have the resources to do it well, and only if you genuinely have a need for it. We were building the base for future functionality that native HTML does pretty poorly: searchable/filterable lists, and multi-selects (native multi-select on Mac requires you to know to hold down CMD or SHIFT to select multiple options, for instance, and has always tested badly for me).
At it’s most basic, it seems deceptively simple. You click on a select, a box pops up with a list of options, you scroll or click on one, the box closes and your option is reflected in the select. Let’s dig into it.
Semantics
The options list in native controls is an example of what’s called a replaced element, where the OS or browser takes over and replaces the element with it’s own. This usually means the ability to style these elements with CSS are limited, and inconsistent across browsers, so to style an options list you need to replace it entirely with a custom built one.
At it’s core, our single select options list functionally works like radio buttons, in that you can only select one option from the list at a time, and can use the cursor keys to navigate between options (more on keyboard navigation later). Because of this similarity, we rendered our list as radio elements for each option, and hid the circular radio control. Obviously if you support multi-selects, you’d need to have these render as checkboxes rather than radio buttons and style appropriately. This isn’t the only approach, but it’s the one that we went with.
One thing to be aware of (that we’ve not yet patched in yet) is that these will by default announce to screen readers as radio buttons, which while it isn’t a blocker, can be a little weird and unexpected for people navigating a select box. This is an easy fix using the ARIA “option” role on the appropriate element.
Option styles
One of the reasons for needing a more custom approach was to allow for styling options and functionality that native lists don’t often support well. This includes icons to the left of options, checkboxes to help the affordance of multi-select lists, as well as dividers and option groups.
We gave designers a few out of the box options in Figma to display icons, checkboxes, and surfaced these as variants on the Options list component. The options list itself has up to 20 items available, with a bunch hidden by default. The list variants swap all the items within it to these checkbox/icon variants, and the dividers and option group headings can be overridden on a list-item level. The major downside to this approach is that reordering things, or adding something once it’s in the list is a huge pain. Hopefully Figma will support a ‘slots’ concept properly in future that allows arbitrary content within a component, and supports reordering etc.
In code, integrators have pretty free control over each list item, and can customise them as needed.
Positioning the popover
Positioning is one of the most complex behaviours we had to implement. When you click on a select, you usually expect the options to pop up underneath the select. But what if the select is too short for the options displayed? What if it’s all the way to the right of the screen? What if it’s scrolled to the bottom of the window?
The most important thing is that the options are visible and selectable quickly and easily. This means you need to be able to specify a default position and alignment for your options list, but also detect when it’s being occluded by the edge of the container or window, and reorient to a better position.
While building this, we identified that this same behaviour was not only useful to options lists, but tooltips, and dropdown menus as well. We split this functionality out into it’s own “Popover” component that could then be re-used across our Tooltip and Options list implementations, or used on custom components that weren’t yet a part of the design system.
The constraints we’d placed on ourselves as a design and engineering team meant we wanted to ensure code was as small as possible, and within our control as much as possible (due to being an open-source system). This meant integrating external libraries was always a no-go for us, so we built this logic from scratch. If your project or system doesn’t have these constraints though, look to something like the Popper JS library, which is a fantastic implementation of this same concept with a ton of options.
In Figma, because of the huge number of possibilities available with all orientations, alignments, and width differences, we made the choice to split the Options list and Select into completely separate components. When a designer needs to show the options list in a mockup or prototype, they compose them together in a way that makes sense, according to the documentation and possible options in code, as documented in Storybook. This just simplifies a bunch of things, and is still very easy to use.
Keyboard navigation
The last major hurdle was making sure accessible and usable keyboard navigation worked as expected. Here are the states we accounted for:
When someone tabs onto the control, it’s given a focus indicator but the options list isn’t shown.
When the select box has focus, pressing SPACE, or Cursor DOWN/UP invoke the options list. The select box loses focus and the item in the list gets the focus indicator.
From here, DOWN/UP and LEFT/RIGHT navigate the user to the next and previous items in the list.
SPACE or ENTER on an item selects it, closes the options list, and focus is given back to the SELECT with the selected item shown.
If ESC or TAB is pressed while the options list is open, the options list is closed and the SELECT is given focus with whatever item was previously selected.
In addition to these, we paid attention to how some mouse interactions differed to keyboard.
Clicking on the Select focuses it and immediately shows the Options list, shifting focus to that.
Clicking anywhere outside the Options list dismisses it (in the same way as ESC does) and returns focus to the select.
We ensured mouse-wheel up or down didn’t change options on the select when it had focus. In testing we found this behaviour unexpected and frequently resulted in people accidentally selecting the wrong option when they wanted to scroll the page.
In summary
You can see there are many little ‘gotchas’ to avoid when building more complex components like this. I’m certain this isn’t exhaustive and there are areas we still need to improve. For example, one of the next things on our Castor roadmap is supporting CSS’s ‘prefers-contrast’ mode across all elements, which is an accessibility preference we currently don’t support well. Disabled states/options are supported to aid with adoption, but are advised against, and we actively try to work with designers to replace disabled elements with other alternatives.
I hope native elements make some progress and improve their feature sets and customisation in future to make these kinds of custom controls a thing of the past, as a lot of products have a need for them, and way too many people develop them poorly.
When embarking on this challenge, make sure you involve your engineering or design partners throughout the process, talk tradeoffs all the way through, and ideally get some sort of external accessibility-focussed testing done on your solutions.
For more component breakdowns, check out the other parts in the series: Button, and Checkbox and Radio.
Elsewhere
Nathan Curtis of EightShapes released the first version of the EightShapes Specs plugin for Figma. Automatically mark up and layout your component specs for engineers on your design system team.
Derek Briggs has a great thread summarising an approach to using shadows for card borders that produces brilliant results with few of the traditional downsides of shadow approaches.
Design System Documentation company ZeroHeight released their “How we document” survey report for 2022.
Hey Steve, is the design system available in the Figma community? I'd love to take a look at this component. Very interesting build. We are currently in the process of doing this component so it'd be so much helpful.