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.

Basic pattern debug view.

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.

Scrolling pattern debug view.

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:

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.

We start with a scrolling set of checkboxes with a skip link. Add JavaScript and the skip link goes away as the set converts to a disclosure widget that shows a count of how many are checked. And it dismisses on Esc.

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

Reply

No love for putting them in an unordered list?

Gamanuel Fleurmond; . Permalink
In response to Gamanuel Fleurmond. Reply

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.

Reply

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, adding overflow-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 an outline complicates giving it focus styling. Keeping the overflow on the fieldset but moving the grid to a child div that wraps everything after the legend also works at the “cost” of adding an otherwise unnecessary element.

Curtis Wilcox; . Permalink
In response to Curtis Wilcox. Reply

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.

Reply

Interesting :) What do think about placing the button and div in a details/summary element?

Martin Lexelius; . Permalink
In response to Martin Lexelius. Reply

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.

Reply

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.

James Scholes; . Permalink
In response to James Scholes. Reply

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).

Reply

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 to type="radio"

In response to Conrad. Reply

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.

Reply

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!

Reply

This is truly a master piece!

Thanks for sharing!

well1791; . Permalink
Reply

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 Muheim. Reply

Josua, your first example is a bit different than this one in that your trigger is the text box. Without the combobox role, the aria-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

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>