Under-Engineered Multi-Selects
This post is not about <select multiple>
nor a bunch of <div>
s roled-up into a listbox
with aria-multiselectable
. Both the APG examples and native HTML control have tested poorly with users for more than two decades.
This post demonstrates a different way to let users select multiple choices from a set. Ideally one that is less confusing and more forgiving of mistakes. Nielsen Norman Group has a reasonably good post on the challenges with any approach, even though it mis-names nearly every control it shows (including the sin of using the word dropdown to mean at least two different things)
The Basic Pattern
Ok, with that exposition out of the way, here is the pattern:
See the Pen Under-Engineered Multi-Selectables by Adrian Roselli (@aardrian) on CodePen.
Essentially it is a pile of checkboxes in a fieldset, with a link to skip past them all:
<fieldset>
<legend>Mammals</legend>
<p>
<a href="#Skip">Skip to next group</a>
</p>
<input type="checkbox" name="Mam" id="Mam01" value="Aardvark">
<label for="Mam01">Aardvark</label>
<input type="checkbox" name="Mam" id="Mam02" value="Babirusa">
<label for="Mam02">Babirusa</label>
<input type="checkbox" name="Mam" id="Mam03" value="Chipmunk">
<label for="Mam03">Chipmunk</label>
<input type="checkbox" name="Mam" id="Mam04" value="Douc">
<label for="Mam04">Douc</label>
…
</fieldset>
The <fieldset>
provides the grouping context, the <legend>
provides the accessible / group name, and the link lets a keyboard user bypass the entire thing without having to tab through a gajillion checkboxes.
The checkboxes are native HTML, so operating them should be familiar while also providing a clear visual cue to what has and hasn’t been chosen. If you want checkboxes that look different from the browser defaults, read my post Under-Engineered Custom Radio Buttons and Checkboxen, which keeps native HTML checkboxes under the fancy design.
CSS grid handles layout so I don’t need to wrap everything in layout <div>
s. It also keeps the text from wrapping under the checkbox. The only curveball is the skip link, which I have span both columns.
fieldset {
display: grid;
grid-template-columns: 1.5em auto;
gap: .5em 0;
}
fieldset > * {
align-self: start;
}
fieldset > p {
grid-column: 1 / span 2;
margin: 0 0 .5em 0;
}
To recap: checkboxes in a fieldset with a skip link using CSS grid and done.
You Hate Tall Content
You may not like that example if it is too tall for your page. Maybe you have a few of these in a sidebar and you want more than one to be visible at a time. Or you have a fixed height screen. Or you enjoy watching users scroll a small box inside a bigger box.
See the Pen Scrolling: Under-Engineered Multi-Selectables by Adrian Roselli (@aardrian) on CodePen.
The first thing I do is is make the <fieldset>
focusable by giving it a tabindex
:
<fieldset tabindex="0">
<legend>Mammals</legend>
…
</fieldset>
Then I use that tabindex
in my CSS selector:
fieldset[tabindex] {
max-height: 11.5em;
max-width: 20em;
overflow: auto;
}
I set the overflow
and max-height
to turn it into a scrolling container. Ideally the height should partially clip the bottom visible choice as a visual cue that there are more. The max-width
pulls the container scroll bar closer as another visual cue there are more choices — though both Safari on iOS and Chrome on Android do not show scroll bars (Chrome will show them during interaction), and Safari on macOS shows a horizontal scrollbar as well.
The selector relies on tabindex
before setting the height in order to ensure a keyboard user can scroll the container. Since a <fieldset>
already has the group role (implicitly) and accessible name (from <legend>
), I don’t need to do anything beyond the tabindex
in my selector. It is essentially the same technique as I use in Under-Engineered Responsive Tables.
No position: sticky
is needed on the <legend>
since it won’t scroll be default, staying persistently visible without occluding anything in the box.
To recap: checkboxes in a fieldset with a skip link using CSS grid, then making it a focus-stop to set a height, and done.
You Hate Tall Content and Fieldsets
Some folks don’t like fighting with CSS to get a <fieldset>
to look like their favorite flat UI long shadow fluid brutalist masterpiece. That’s ok. You can still get the same effect with ARIA (and yet more CSS):
See the Pen Group Role: Under-Engineered Multi-Selectables by Adrian Roselli (@aardrian) on CodePen.
Scrolling ARIA container debug view.
There is a lot more happening here, some of it required by the change and some of it because I can. Let’s look at the necessary changes first, starting with the HTML:
<div role="group" aria-labelledby="ScrollingGroupLabel" tabindex="0">
<p>
<strong id="ScrollingGroupLabel">Mammals</strong>
</p>
<p>
<a href="#Skip">Skip to next group</a>
</p>
<input type="checkbox" name="Mam" id="Mam01" value="Aardvark">
<label for="Mam01">Aardvark</label>
…
</div>
The group
role replaces the grouping that the <fieldset>
provided, while the aria-labelledby
grabs the accessible name, which the <legend>
had provided. I set the accessible name in <strong>
so it will visually stand out if the CSS fails, making up for the lack of vertical offset the <legend>
provided.
The original CSS selectors need to change as well. As above, and borrowing from my post Using CSS to Enforce Accessibility, I build the selector to ensure it has the role and an accessible name reference. I also add the border I lost by dropping <fieldset>
:
[role="group"][aria-labelledby] {
display: grid;
grid-template-columns: 1.5em auto;
gap: .5em 0;
border: .1em solid #ccc;
padding: 0 .75em;
}
The group name node that replaces the <legend>
should not scroll with the rest of the group content. You could put it outside the group, but I am mimicking the <fieldset>
/<legend>
structure. As such, to prevent text overlap, it also needs an opaque background. That background needs to come from the page or container background. This is probably best handled with CSS custom properties:
:root {
--page-bg: #eee;
--text-color: #333;
}
[role="group"][aria-labelledby][tabindex] > p:first-child {
position: sticky;
top: 0;
background-color: var(--page-bg);
}
Don’t forget to account for the controls that can appear behind the sticky group name (not an issue with <legend>
). Use scroll-padding-top
on the group to create a bit of a buffer.
If I am setting a background on everything then I might as well add the visual cue that this thing scrolls (but only when it has a tabindex
, meaning a keyboard user can scroll it), thanks to styles from Lea Verou’s 2012 post:
[role="group"][aria-labelledby][tabindex] {
max-height: 12.75em;
max-width: 20em;
overflow: auto;
scroll-padding-top: 1em;
background:
linear-gradient(var(--page-bg) 30%, var(--page-bg)),
linear-gradient(var(--page-bg), var(--page-bg) 70%) 0 100%,
radial-gradient(farthest-side at 50% 0, rgba(0,0,0,.2), rgba(0,0,0,0)),
radial-gradient(farthest-side at 50% 100%, rgba(0,0,0,.2), rgba(0,0,0,0)) 0 100%;
background-repeat: no-repeat;
background-color: var(--page-bg);
background-size: 100% 4em, 100% 4em, 100% 1.4em, 100% 1.4em;
background-attachment: local, local, scroll, scroll;
}
I also threw a default focus style into the mix, though I would assume any project where you use this will already have one. Right?
To recap: checkboxes in an ARIA group with a skip link using CSS grid, then making it a focus-stop to set a height, then a few more styles as visual cues while restoring lost fieldset and legend features, and done.
You Hate Tall Content and Fieldsets and Letting People Glance at Stuff
At this point we might as well go all in. Let’s hide all the choices, saving the maximum amount of space, and require the user to interact with a disclosure in order to browse them. This pattern leans on my post Disclosure Widgets.
See the Pen Disclosure: Under-Engineered Multi-Selectables by Adrian Roselli (@aardrian) on CodePen.
Disclosure version debug view.
Some features that are not already covered above or in the posts I have linked throughout:
- Before the JavaScript runs (or if it never runs):
- the container is not collapsed — it fits into the flow of the page;
- it generally matches the previous example, but instead of a group label in the box, the button acts as the visible and programmatic label.
- When the JavaScript runs:
- styles kick in to collapse the box;
- the box will then sit over the content when expanded;
- the skip link is hidden;
- a white background is added so it stands out more from the page;
- the scroll shadows are dropped since the control purpose is more obvious;
- the disclosure trigger becomes functional;
- a counter appears in the button trigger that displays the number of selected items against the total.
- The counter is part of the accessible name of the control. However, as it updates the changes may not be announced by all screen reader combinations, though it is a better option than a live region.
- The disclosure can be dismissed by clicking outside of it (except in Safari), tabbing out of it, or pressing Esc. This example does not set focus anywhere when that happens, but you should.
- I dumped the grid styles and went for classic floats so the entire width of a choice, including below the checkbox, is the clickable label.
- The visual styles may be a bit close to other custom select-like controls, but hopefully the visible checkboxes are a cue this behaves differently.
- This does not support arrowing among items; I prefer letting the user be able to scroll the box to quickly see all the options.
- This is not supposed to be an attractive design. If you don’t like the design then change it, but probably keep the core features until you can test it with users.
- This is not production-ready code. Especially not the script.
To recap: checkboxes in an ARIA group with a skip link using CSS grid, then making it a focus-stop to set a height, then a few more styles as visual cues while restoring lost fieldset and legend features, then using script to enhance it to be hidden until a button is triggered while allowing it to be dismissed, and done.
In other words, still grouped checkboxes at its core.
Wrap-up
You may not need a complex, cumbersome, bloated multi-select widget (especially if managed by a third party that ignores accessibility bugs). HTML may have all you need.
With a little CSS and maybe some script you can turn a stack of checkboxes into a usable control that is able to adapt to your site’s visual design. Users will generally know what to expect, testing should be simplified, and there is one fewer library or component you will need to monitor and update.
Unlike some of my other posts on under-engineered controls and widgets, I am not wading into screen reader announcements, High Contrast Mode, internationalization, CSS logical properties, text expansion, responsive type, error states, conveying required states, dark mode, preferred methods to label the controls, browser bugs, how to style the scrollbars, and so on. I have covered all of those and more somewhere on this site already (as have so many others).
Go forth, prototype, and test with your users!
14 Comments
No love for putting them in an unordered list?
In response to .Nope. Visually they are generally styled away due to the extra visual noise, programmatically VoiceOver won’t announce them once the bullets are styled away, and from testing with other screen reader users they tend to be too verbose when they are announced in this context.
The lack of a horizontal scroll in Mac Safari for the Disclosure version that doesn’t use
display: grid
indicates it’s a bug with Safari laying out grid with overflow. Fixing it also stops it from having horizontal overscroll in mobile Safari. Accounting for browser bugs probably doesn’t belong in an “under-engineered” design but there are a few options for avoiding it.Changing the overflow to
overflow-y: auto
does not fix the problem, addingoverflow-x: hidden
works and the rest of the design should ensure content wraps instead of getting cut off. Removing the inline borders works but identifying the perimeter of the component is desirable and replacing the entire border with anoutline
complicates giving it focus styling. Keeping the overflow on thefieldset
but moving the grid to a childdiv
that wraps everything after thelegend
also works at the “cost” of adding an otherwise unnecessary element.
In response to .Indeed, addressing bugs is outside the scope of this post. I called them out, but I assume a team already has some stuff in place from its own code library to mitigate and/or decide if or how much of an issue the thing is. Thanks for the notes on possible workarounds.
Interesting :) What do think about placing the button and div in a details/summary element?
In response to .The
<summary>
element already maps to a button role, so that would be the disclosure trigger (instead of nesting a button inside the<details>
).That aside, sure, go for it. Above I link to my post Disclosure Widgets, and the Native section says as much. The Native versus ARIA Comparison shows how each is exposed to users by screen readers. The VoiceOver and TalkBack announcements are why I go with the ARIA pattern here.
Whichever you choose, test with your audience.
I’ve often recommended this type of approach to clients, building up the pattern to bring in skip links and disclosures in line with complexity and/or the number of available options. Entire filtering UIs can be built this way, with disclosures, native checkboxes and selects, standard HTML buttons and a bit of focus management/screen reader announcement love.
Having said that: in my experience it does start to fall apart with much larger data sets. When you have so many options that tabbing (or screen reader quick nav) will take forever, there are at least two things you’ll want to bring in:
1. some sort of text-based filter; and
2. quicker keyboard navigation (e.g. type-ahead).A possible third item is some sort of grouping. At that point, I’d be interested to know your thoughts on keeping this as native and usable as possible. Some key challenges include: allowing users to easily navigate out of the sea of checkboxes back to the filter input (hopefully without having to collapse a disclosure and then re-expand it), communicating that additional keyboard behaviour exists that isn’t usually expected of a group of checkboxes, having a screen reader switch to forms/focus mode automatically so the additional keystrokes can be used, and so on.
In response to .My thoughts are once you get to so many options the overall flow should be examined anyway, not just the input methods.
To address keeping this as native and usable as possible:
For non-trained or casual users, a standard
<select>
allows a user to type the first letter of a choice. Use a button to add each selected option to a visible/persistent list. A checkbox next to each item in the list to remove it. How well that scales depends on amount of data, user experience, the rest of the flow, how many choices the user may make, and so on.For trained or regular users, a type-ahead may be fine or even ideal — context is king here, and users should ultimately have a say in this. But once you start to add that kind of custom control you also need persistently visible instructions (or at least persistently available).
Please change the name of all input fields to
name="Man[]"
(with brackets) so you have a working example where actually all checked fields/values get submitted. Currently only the latest checked value would be transmitted. Or change them all totype="radio"
In response to .Conrad, all values are submitted / transmitted. If you grab the basic pattern debug view, use the dev tools to throw an
<input type="submit">
before the closing</form>
, and submit it with all checkboxes checked, then you will see all of them in the query string (not just the last value):?Mam=Aardvark&Mam=Babirusa&Mam=Chipmunk&Mam=Douc&Mam=English+Cream+Golden+Retriever&Mam=Gibbon&Mam=Honey+Badger&Mam=Impala&Mam=Jaguar&Mam=Kodkod&Mam=Lemming&Mam=Marmot&Mam=North+American+Black+Bear&Mam=Otter&Mam=Pangolin&Mam=Quokka&Mam=Rhinoceros&Mam=Saiga&Mam=Treeing+Tennessee+Brindle&Mam=Unau+%28Linnaeus’s+Two-Toed+Sloth%29&Mam=Vicuña&Mam=Warthog&Mam=Xoloitzcuintli&Mam=Yak&Mam=Zebu
If I change them all to use
name="Man[]"
, then I get the same thing but now with URL-encoded brackets in the query string:?Mam%5B%5D=Aardvark&Mam%5B%5D=Babirusa&Mam%5B%5D=Chipmunk&Mam%5B%5D=Douc&Mam%5B%5D=English+Cream+Golden+Retriever&Mam%5B%5D=Gibbon&Mam%5B%5D=Honey+Badger&Mam%5B%5D=Impala&Mam%5B%5D=Jaguar&Mam%5B%5D=Kodkod&Mam%5B%5D=Lemming&Mam%5B%5D=Marmot&Mam%5B%5D=North+American+Black+Bear&Mam%5B%5D=Otter&Mam%5B%5D=Pangolin&Mam%5B%5D=Quokka&Mam%5B%5D=Rhinoceros&Mam%5B%5D=Saiga&Mam%5B%5D=Treeing+Tennessee+Brindle&Mam%5B%5D=Unau+%28Linnaeus’s+Two-Toed+Sloth%29&Mam%5B%5D=Vicuña&Mam%5B%5D=Warthog&Mam%5B%5D=Xoloitzcuintli&Mam%5B%5D=Yak&Mam%5B%5D=Zebu#
I use
method="get"
on the<form>
because I find it handy for quick debugging like this.To your other point, if I changed these to radio buttons it would no longer be a multiple-select pattern.
Love this.
“Some folks don’t like fighting with CSS to get a
<fieldset>
to look like their favorite flat UI long shadow fluid brutalist masterpiece.”That right there is the reason that fieldset/legend is the one time I condone breaking the first rule of ARIA. That and because I’ve noticed that Voiceover seems to double-announce legend elements.
That said, I didn’t know that legend will do that magical scroll-sticky-ness on its own. TIL. Thanks for sharing!
This is truly a master piece!
Thanks for sharing!
Thanks for this exhaustive explanation, Adrian.
I held workshops about this very approach years ago already (for searchable single-select comboboxes, as described in our “Accessibility Developer Guide”s Autosuggest pattern, see https://www.accessibility-developer-guide.com/examples/widgets/autosuggest/). I’m impressed/frustrated by how many “senior” web developers still reside to “/“ soups instead of using “real” HTML. But it’s good to see that other people have similar ideas, too.
In the last months we implemented a prototype of a multi-select version using checkboxes. It is even filterable: https://josh.ch/dropdown/multi.html
Let me know what you think about it. We are currently in the process of implementing it as a reusable web component, by the way.
In response to .Josua, your first example is a bit different than this one in that your trigger is the text box. Without the
combobox
role, thearia-expanded
could have unexpected results. And because of the autocomplete feature, it is might be a bit verbose (announcing the quotes with the live region).The multi-select has the
combobox
role, which is good. It is also a very different pattern than mine in that it has a filter and generates “pills”. FYI that the question mark doesn’t seem to do anything (the hidden instructions say to enter a question mark for help).Obviously both your controls have very different use cases from what I am outlining in this post, so I leave it up to the reader to identify if they might be a fit.
Leave a Comment or Response