Under-Engineered Select Menus
I am still confounded how many developers and designers see a <select>
and immediately reach for a library or framework to re-create the features from the ground up. Though, frankly, I am confounded any developer or designer reaches for a <select>
at all.
Judgments about the value of select or select-like controls aside, it is a fool’s errand to try to re-create them and still be accessible. If you do not believe me, go read Sarah Higley’s treatise <select>
your poison.
Hopefully I can short-circuit the question, “Do I need a custom select?” by pointing out that you can style a native select today (ok, almost three years ago)!
The easiest thing to do is go to The Filament Group’s post Styling a Select Like It’s 2019, and grab their code. I assure you that it is good. I even took the time to add RTL support and disabled styles.
You could stop reading here and be on your way, but you may find the Filament styles a bit much (I am meh on the roundedness), or you may want to use as little CSS as possible, or you may want to match the styles of my other under-engineered controls. If so, then read on.
Basic Styles
A more robust style might look like this:
select {
font: inherit;
letter-spacing: inherit;
word-spacing: inherit;
}
That should look familiar. It is from my post Under-Engineered Text Boxen. Those three styles handle a few things for you.
font
The font
property will inherit all the styles from the container(s). Some CSS resets will double up and use font-size
, but that is not necessary. font
will bring in the styles for each of font-size
, font-size
, font-family
, font-style
, font-variant
, font-weight
, font-stretch
, and font-height
(when set). It also brings in line-height
. That also means it will adapt to changes you make to the page, either with add-ons or via browser / system settings.
letter-spacing
If a user (or author) adjusts the text to provide more space between letters, the default fields are not affected. Inheriting the font
styles does not bring letter-spacing
styles along with it, so you need to inherit letter-spacing
as well. If you otherwise take steps on your page to support WCAG 2.1 Success Criterion 1.4.12: Text Spacing (grab Steve’s handy testing bookmarklet), excluding this declaration would prove disappointing for users who rely on that control. This style is also in no CSS resets that I found. Sadly, the options of the select are unaffected.
word-spacing
Similarly to letter-spacing
, the default fields will not inherit word-spacing
styles unless you explicitly declare it. WCAG 1.4.12 applies for word-spacing
as well. This style is also in no CSS resets that I found. Sadly, the options of the select are unaffected.
line-height
Note that I do not set line-height
. It cascades from font
in all the browsers I tested. If you find a case where it does not, likely due to explicitly setting line-height
or more specific styles, then I suggest adding line-height: inherit
to satisfy WCAG 1.4.12.
Example
The following example (available at CodePen and also in debug view to get rid of Codepen code) shows these minimum styles.
See the Pen Under-Engineered Select, Simple by Adrian Roselli (@aardrian) on CodePen.
But You Want a Special Arrow
Typically folks want to replace the default arrow, the indicator that this is a select control, with something closer to their design system. Well, Filament Group has done that. I have whittled down the styles to the bare minimum to do so.
select {
/* mine from above */
font: inherit;
letter-spacing: inherit;
word-spacing: inherit;
/* from Filament */
-moz-appearance: none;
-webkit-appearance: none;
appearance: none;
}
select:not([multiple]) {
/* mine again */
padding-right: 1.2em;
background-repeat: no-repeat;
background-position: calc(100% - 0.25em) 0.35em;
background-size: 0.85em auto;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 80 80'%3E%3Cpath d='M70.3 13.8L40 66.3 9.7 13.8z' fill='%23000'%3E%3C/path%3E%3C/svg%3E");
}
/* Filament: Hide arrow icon in IE browsers */
select::-ms-expand {
display: none;
}
Essentially these remove the browser style, make room for a graphic arrow, and then insert the graphic arrow as a background. I only apply this if the select is not a multi-select.
The following (also available at CodePen and in debug view as well to get rid of Codepen code) shows the code with your special arrow.See the Pen Under-Engineered Select, With Your Special Arrow by Adrian Roselli (@aardrian) on CodePen.
Don’t Stop Here
You probably noticed the spacing around the text is poor. You can potentially make these more attractive, usable and accessible for your users with a few additional styles, tweaked for your own design. You can read a high-level overview of what to consider in my post Basic Custom Control Requirements.
Here is a version of the same styles as above, but with a couple more added to match those from my Under-Engineered Text Boxen:
select {
/* the bare minimum */
font: inherit;
letter-spacing: inherit;
word-spacing: inherit;
/* from Filament */
-moz-appearance: none;
-webkit-appearance: none;
appearance: none;
/* match my text boxen styles */
border: 0.1em solid;
padding: 0 0.2em;
}
Additional Styles
The following two styles extend the basic styles above to hopefully make the text fields more usable.
border
Setting a border brings risk. Per WCAG (SC 1.4.11: Non-text Contrast), if you make no changes to the default styles of a form element you are not on the hook for contrast. But we want to make the control more usable. This style sets a thickness based on the font size, so it will scale with the text. It also makes the border thicker than the default in most browsers. I expressly do not set a color, letting the browser either leave it or set it to initial
. If your page background is not white, you may want to adjust this.
padding
The padding
is not necessary, but I have found in testing that fields are a little easier for users to read and use when there is some breathing room. It also makes the spacing between these <select>
s closer to <textarea>
s and <input>
s, matching them if you use these styles on those fields as well.
State Styles
Select menus can come in different states. Disabled, focused, errored, and required are the most common I see in forms and also among the most inconsistently styled across the web. You don’t have to use any of the styles I propose, but you can use the selectors and thought process behind them to inform how you might implement them for your own pattern library.
Disabled
Disabled fields are excluded from WCAG contrast requirements, as are default interface controls. If you simply do nothing, the browser does the work for you. In the spirit of under-engineering, since I did not adjust the field background color nor text color, I can let the browser do the work.
Focused
WCAG SC 2.4.7 allows you to do nothing here, per Technique G149, because the browser adds its own focus style. As I argue in Avoid Default Browser Focus Styles, this just won’t do. I opt to use an obvious blue outline, with good contrast to white, and set a box shadow as well to make it even more obvious. I tend not to apply this to hover styles.
select:focus {
outline: 0.15em solid #00f;
box-shadow: 0 0 0.2em #00f;
}
Read-Only
A native <select>
does not support the readonly
attribute. So carry on.
Required
As long as your label indicates a field is required (along with the required
attribute), you don’t need to style the field any differently. A few years ago, however, I experimented with a visual style to reinforce the label and it tested well with users for that system. I thickened the border on the left and nothing more, which seemed to provide enough guidance at a glance. I do not change the color, continuing to let it inherit (from user agent styles by default).
select[required] {
border-left-width: 0.3em;
}
Errored
A red border, alone, will always be insufficient (from both contrast and color-alone WCAG failures) and massive drop shadows can muddy the overall page. In testing with users, too much effort to draw attention to errors creates noise, requiring multiple passes for users to address them all. Instead, I found that an indicator in the corner of the field did the trick. The gradient making the red mark sets the field’s background to white, the first time we explicitly set a field background color.
I skip :invalid
in my selector because that keys off native browser error handling, which is often not what you want. I use the presence of aria-invalid
instead, prompting users to add or remove the attribute, not just toggle it from true
to false
. Because the arrow is also a background image, I have to re-set it in this style when it is not a multi-select.
select[aria-invalid] {
background: linear-gradient(
135deg,
rgba(255, 0, 0, 1) 0,
rgba(255, 0, 0, 1) 0.4em,
rgba(255, 255, 255, 0) 0.4em
);
}
select[aria-invalid]:not([multiple]) {
background-repeat: no-repeat, no-repeat;
background-position: 0 0, calc(100% - 0.25em) 0.35em;
background-size: 0.85em auto;
background-image: linear-gradient(
135deg,
rgba(255, 0, 0, 1) 0,
rgba(255, 0, 0, 1) 0.4em,
rgba(255, 255, 255, 0) 0.4em
),
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 80 80'%3E%3Cpath d='M70.3 13.8L40 66.3 9.7 13.8z' fill='%23000'%3E%3C/path%3E%3C/svg%3E");
}
Internationalization Styles
I am going to cheat a bit here and just speak to right-to-left styles. I trust you who are more familiar with writing modes can adapt what I cover here to your use cases.
Conveniently, since we are using native fields, we only need to adjust styles we made that lean on expectations from left-to-right languages, namely the arrow, required, and error styles that position themselves based on where the user starts reading. Move the arrow, red mark, and the thicker border to the opposite side and you are in good shape. Obviously there is a risk I am missing some cultural implications (in RTL Elbonian, a red triangle on the right might mean all is well and that you are late for supper).
*[dir="rtl"] select:not([multiple]) {
padding-right: 0.2em;
padding-left: 1.2em;
background-position: 0.25em 0.35em;
}
*[dir="rtl"] select[required] {
border-left-width: 0.1em;
border-right-width: 0.3em;
}
*[dir="rtl"] select[aria-invalid] {
background: linear-gradient(
225deg,
rgba(255, 0, 0, 1) 0,
rgba(255, 0, 0, 1) 0.4em,
rgba(255, 255, 255, 0) 0.4em
);
}
*[dir="rtl"] select[aria-invalid]:not([multiple]) {
background: linear-gradient(
225deg,
rgba(255, 0, 0, 1) 0,
rgba(255, 0, 0, 1) 0.4em,
rgba(255, 255, 255, 0) 0.4em
),
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 80 80'%3E%3Cpath d='M70.3 13.8L40 66.3 9.7 13.8z' fill='%23000'%3E%3C/path%3E%3C/svg%3E");
background-repeat: no-repeat;
background-size: auto, 0.85em auto;
background-position: 0 0, 0.25em 0.35em;
}
Windows High Contrast Mode Styles
Because we are leaning on native controls, generally not messing with colors, letting system styles apply when appropriate, and avoiding styles for critical features that are dropped in WHCM, we don’t have much to do here. The outline
style adapts fine, the disabled
defaults work, and border styles do what they need to do.
The only thing we lose is the error indication. With so few (WHCM system) colors to choose from, you would have to lean on another visual indicator for errors. At that point, you are relying on your field labels to clearly indicate errors.
Print Styles
Because you are scaling the border thickness based on font size, you can ignore that as the user is likely choosing a font size that is legible. The backgrounds of the disabled and errored fields can be controlled by the user in print settings. As long as you set a page text size in your print styles, there is nothing for you to do here.
Dark Mode Styles
As dark mode is a feature query that you have to create styles to support, this is all on you. You will have to choose a background color and font color and go from there. Conveniently, you can override colors alone and be in a better position than writing all new styles from scratch.
You will need to inherit the text color and background (or make it transparent) and choose a border color that still passes contrast requirements. The focus styles also need sufficient contrast. The error state relies on a background gradient to white, so you will need to redefine the error styles all over, including for RTL. The disabled fields will also need some work, since you are explicitly setting border and text colors now.
Extended Example
The following example showing the expanded styles is also available at CodePen and has its own debug view to get rid of Codepen code.
See the Pen Under-Engineered Select Menus by Adrian Roselli (@aardrian) on CodePen.
Don’t Use a Multi-Select
If someone suggests you use a multi-select, specifically <select multiple size="#">
, don’t. Kick them out and use a bunch of checkboxes instead. Maybe a few columns if there are a lot, or a scrolling container. Or even a disclosure widget containing piles of checkboxes.
Multi-selects have always tested poorly across all groups where I have tested them. Also, I hate them too.
My styles still support them, so if you have an edge or corner or down-the-street case then these styles will still work.
Wrap-up
In time, this post may be unrelated to reality. I might be ok with that. Until then, you can do a lot with native HTML fields using just a little CSS. When you do style your own fields, look to the minimum styles you need to make them fit with your overall design and still support the cases I outline above. You don’t need to do them all, but I recommend you at least consider (and test) options.
Update 4 September 2021: This Fixes iOS Zoom
When I first used font: inherit
with <select>
I also happily discovered it prevented iOS from zooming into the field, an annoyance it likely did because the default text was so small.
The Filament Group’s 2018 post Styling a Select Like It’s 2019 includes this handy nugget:
- The
font-size: 16px;
rule is important because iOS Safari will zoom-in the site layout if theselect
‘s text is less than 16px. Generally, this behavior is annoying so we try to avoid it with a 16px font size onselect
s.
But that is no longer true and hasn’t been for a while (and I am not sure it was true then). Instead of setting an arbitrary font size that may be different than your other content, you can use font: inherit
.
So here we are in late 2021 and people are still setting font-size
to 16px, or making it even more convoluted with min(100%, 16px)
or max(100%, 16px)
when you can use inherit
and let the cascade do its job. Besides, as that thread shows, min()
/max()
may work opposite from the way you expect.
The good news is iOS 15 should remove this weird zoominess (and update select menus in general). Which is all the more reason to move to using inherit
now versus letting your 16px hack ossify in your codebase for later maintainers to be afraid to excise.
I made an example you can test in your iDevice, without the Codepen wrapper.
See the Pen Stop Setting 16px to Stop iOS Zoom by Adrian Roselli (@aardrian) on CodePen.
Update: 24 March 2022
If you are hoping accent-color
will be your friend now that it has support across all major browsers, it will not help here. It only works with checkboxes, radio buttons, range sliders, and progress bars. There is a write-up at CSS accent-color and a test page.
However, in Firefox it becomes the default focus ring color if you do not otherwise have a custom focus style. So ensure it has good contrast.
Update: 26 April 2022
On mobile devices you can dismiss a native <select>
with a gesture. This is handy when you are perusing options but do not want to select the one that has focus. If you use the same gesture on Google’s Angular mat-select (the only one I tested), then you instead navigate backward in your browser history. Which can be a real bummer in the middle of filling out a form.
The lesson here is that you should probably test your custom controls across a range of users, experience, technologies, and interaction types.
10 Comments
I’m confounded at your overuse of the word confounded .
In response to .I appreciate you noticing, though I am confounded I did not get more instances of confounded into this post beyond the opening paragraph.
Great info. One small typo. Because the arrow is also a background image, I have to re-set it in this style when it not a multi-select.
In response to .Fixed. Thanks!
Quick heads up. For some reason the final example’s select in error state red triangle isn’t appearing properly on iOS browsers.
In response to .Chris, yeah, the error state for LTR single select is odd in Safari on iOS (all iOS browsers are WebKit under the hood). I say odd because it appears to work fine on the multi-select and on the RTL select. It looks like the nested option is occluding it, but I cannot style that away (at least not after some quick tests).
Clearly more effort needed. And if you do not use my method of visually displaying errors, then not a problem. Still annoying.
Would you mind expanding on why you think it is foolish to reach for a select at all?
In response to .I did not say foolish, I said I was confounded. I am confounded because their interaction and understanding cost is high.
For only a few options, to see them all I have to activate the control just to view them and parse the options. A stack of radio buttons is faster to peruse (no action to display the list). For longer sets of options, a scrolling container of radio buttons or checkboxes (if a multi-select) has at least one fewer action (nothing to expand) and options persist even when elsewhere in the page. In fact, that is the pattern I recommend in my post Under-Engineered Multi-Selects.
I couldn’t find a specific post on your website regarding multi-thumb sliders or any discussion of slider widgets. So, I’m just going to comment here:
There are some pretty good posts (in terms of broad discussion) regarding multi-thumb slider widgets on CSS Tricks such as: https://css-tricks.com/multi-thumb-sliders-general-case/
But, the one thing I never see discussed is the accessibility implications of *unbounded* multi-thumb sliders. What do I mean by this?
Let’s say I have an age range slider (min / max for each thumb). Pretty standard case.
In most implementations you will see the behavior is for the user to be able to slide the thumbs *beyond* the *other* thumb.
Technically, this isn’t really semantic in my opinion. You’re swapping the order of the defined representation (min vs. max) unless you are dynamically updating that via JavaScript (I have never seen anyone do this) and even if it was updated it seems to be relatively janky.
So, I’m curious on your take of this behavior and if you think people should set hard boundaries. Meaning, the lower end cannot exceed the upper end range thumb (aria-valuemax for min thumb < aria-valuemin for max thumb) and vice versa. This is the behavior I implement and believe is correct.
I do agree the prior is not as user friendly in terms of looking fancy / smooth UX as it does require more interaction which is arguably worse behavior for those with motor control issues.
In response to .Nick,
While I am unlikely to reach for a slider to define an age range (nor do I recall seeing one in the wild), I doubt I would set hard boundaries. Letting someone grab whichever thumb-ball they want and placing it wherever seems fine. But I would prototype it and put it in front of my users to see how they actually use it.
Also, Ana Tudor’s 2020 CSS-Tricks articles were, in my opinion, among the best implementations I had seen (part 1 of the link you provided). I also have not tried them in some time to what kind of updates, if any, might be warranted.
Leave a Comment or Response