Selectalike: A more styleable CSS-only select replacement
The concept in this article only works in IE9+ due to use of certain CSS3 selectors. Other browsers have supported them for quite a while. It isn't impossible to make them IE8 compatible, but doing so is not covered by this article. If such support is needed and you aren't willing to adapt what is in this article, I would bail and look elsewhere.
As it stands: the raw element
First let's take a look at a standard select as rendered by various browsers with custom styles applied. You can skip this section if you're already quite familiar with the failings of form element styling.
Firefox 23 (Windows)
Chrome 28 (Windows)
IE10 (Windows 7)
It is quite easy to see that most styles are applied as asked for the main select body, but there remains some inconsistencies with the menu. Firefox was always the most liberal in applying select styles and lets us change the font-style. The rest ignore it. None of them respect the border styles for the menu, color or radius. Lastly, all have this unstyelable button we can't do anything about. (IE10 has one too, but it is covered up.)
I think this is about as good as it gets for a raw select. Pretty ugly if you ask me. I'd much rather have the raw original if this is all we can do. This is not a dead end, however, more can be done. We just have to stop thinking about selects for a moment.
- Radio buttons
Radio buttons allow a user to select only one item out of many, even though they take up more space on the interface and are even harder to style. Lucky for us we're not after their styles but the behavior of labels when linked with an input via the for attribute. In all browsers, clicking on an associated label focuses the input it is linked with or selects it as appropriate for that input type. This is the first step to freedom: being able to place items which are very styleable anywhere we want while still storing the user's choice.
Building the replacement
Having found a suitable feature-equivalent set of elements, we can get back to thinking about selects.
Even so, there is one state which stands out as having the ability to stick around long enough to be useful and requires explicit user interaction to trigger: focus. It is better than hover at achieving our goal because an element retains it even if the user is no longer hovering the element and like the select, the focus state is removed when the user gives something else focus or clicks somewhere else.
The following example demonstrates this behavior and the basics of direction towards the replacement we want. There's a lot in this example, so afterwards I will deconstruct it and briefly explain parts of the example and reasons for the choices made.
Click me, then somewhere else.
The properties applied to the div are to make it behave like an inline element with a fixed height to match a real select. If you open a select, the menu doesn't push down the items after it and we don't want to either. Due to the click forwarding of paired labels, we don't actually need the inputs themselves to be visible or even in the flow. They can be safely hidden where they can't interfere. We can't remove them entirely because, as mentioned earlier, we still need a form element to remain on the page to hold a value that gets sent to the server on form submission.
The remaining styles ensure the labels stack vertically, are hidden by default, and show up when their parent gets focus. We actually want the label that corresponds to the user's or default choice to stay visible when the parent doesn't have focus but that will be fixed later in this article. The reason for hiding each label instead of setting an overflow on the parent is to give us more flexibility as to what happens to the labels after focus is lost on the parent. Overflow would cause the children to disappear immediately preventing processing of some events. For example, Firefox will process focus changes from a click and all effects of the focus change before it notifies elements a click occurred. If the focus change caused the element to move out from under the cursor or disappear, Firefox will not dispatch a click event to it. In the case of our labels this would prevent the associated inputs from being selected. This will be important to remember when we get around to closing the menu on click.
Click me first, then the checkbox.
But wait, what about that vertical-align? I honestly don't know. Without it, the children push any items after the div down even though I don't think they should due to the height set on the parent. It's magic. If anyone knows, please let me know and I will edit this article with what it does.
On the HTML side of things, the
tabindex attribute on the div is quite important. It tells
the browser the div is allowed to gain focus, even though it normally can't. The
value of zero indicates the div is to be included in the
focus navigation cycle based on where it sits in the flow: after the last focusable element, but before the next. Lastly, it
shouldn't have escaped notice that I also reordered the children. The inputs have been moved before and away from their labels.
This is because we want to be able to select certain labels based on the checked state of any input, which cannot be done if any
of the inputs are ordered past at least the first label in the source. This will make more sense in the next section.
Showing user selection
The basic code above gets us on our way, but doesn't show the value the user clicked on. Borrowing those styles and the same markup (excepting for the unique ID requirement) we can add some selectors to accomplish this. Since all the labels are in the flow we can easily move the selected label in to view by changing the top margin of the first label on the assumption that all labels are the same height:
This works pretty well except for the use of some long selectors, the number of which is dependent on the number of labels. The reason for this is because the offset by which the children are moved needs to increase with every label to bring it inside the parent's visible area. The second set of selectors has a similar issue: only the label corresponding to the selected input should stay visible when focus is removed from the parent. Since I cannot select a label by its for attribute based on the value of an input's id attribute, I have to write a selector for each pair explicitly. Unfortunate. I don't believe all of this long set of selectors could be avoided if this replacement were top-anchored—where the menu doesn't shift based on the chosen value like the real select—as it seems the last selector block would still be needed.
Secondly, to deal with the problem of needing n selectors for n labels a large number could be pre-defined up to cover any number of potential lengths that might be encountered on a site. Not the greatest solution. They could be generated via script instead, but that defeats the idea of being CSS-only. If only I could could use the CSS placeholder value "n" and have it mean the same value in both parts of the selector. Then I would only need one line for the second selector block. Alas CSS only offers to treat them independently.
Finally we should deal with the issue of the rectangle appearing blank by default. This is easily solved by the making one of the inputs selected by default in the markup with the addition of a checked attribute.
Click me, then somewhere else.
By now it's probably obvious the menu doesn't close when a value is clicked on, only when focus on the parent is lost. Real selects don't behave this way so we should modify the behavior of our replacement. The first step to doing this is give the labels inside the ability to steal focus. Ordinarily the inputs associated with the labels would steal focus when the label is clicked, but this doesn't happen when the inputs are hidden. The same trick that makes the parent focusable can be used here:
Once the labels are focusable, we need to add a few selectors to compensate for the disappear-before-click problem noted in a prior section. One way to do this is, as below, make a selector which keeps the label visible while the cursor is over it so it stays long enough for the click to fully register.
As soon as the the input becomes selected, though, the label should hide itself behind the parent again. If it doesn't, it will become impossible to select the parent again to open the menu as it will be covered by the label. This is easy enough to do by modifying a selector used previously:
Unfortunately now that the previous selector is quite specific, the labels associated with checked labels won't come to the top when the parent is focused. This is desirable because the currently selected value can be selected again to close the menu without having to click somewhere else or select a different value. The following monstrous selector is useful to this end. It is only so long to be more specific without using !important which would allow a shorter selector.
Finally all of those can be put together to realize a quite useful replacement.
Styling and polish
… except it's ugly. Don't tell me you didn't think that. It looks terrible. Nobody would use it as-is on a site, but that's fixable! This section contains suggestions for styling to make the replacement look great. However you're free to do anything you want for styles as long as the core parts most of this article is dedicated to are still present.
Basic menu styles
These styles can be integrated with previous selectors; I didn't copy in their content for brevity. As a quick overview, these styles give the replacement a darker border on focus and around the labels as well as a background for all the labels.
Affordances and other final touches
It's a nice rectangle, but I think a bit better can be done. It doesn't quite look like a select. As a user, I might not know that I can click on it or what it does. The general concept of how an object makes it obvious what to do with the object is known as an affordance. One way to provide this is a triangle, like the original select, to indicate that when clicked a menu will appear. Since it moves in both directions I prefer to have two small arrows but this is up to personal preference.
The previous styles hide the :before and :after elements because they should only show up on the label that sits inside the visible box of the parent when closed. The following selector does that, but is also in addition to the previous selectors using nth-child and nth-of-type and not a replacement even though they look similar.
This selector could be avoided if the before and after were placed on the parent instead, but since selectors cannot walk back up the tree there is no way to color it white when hovering the label inside the parent's box which is nicer when adding a hover effect to labels as shown below.
and there you have it! A complete select replacement that not only looks good, but you can style it to your heart's content. For a complete all-in-one example of this, please see the demo page. Thank you for reading.
Credits: reisio from #css was originally working on the concept as a hover-triggered replacement before I decided to take it more closely to the select using focus.