Multi Select
stimeo--multi-select
An input that filters choices and takes several of them, shown as chips you can remove.
Filters choices as you type and lets you take several. Focus stays in the field while screen readers are told which choice is current. The arrow keys wrap around and Home and End jump to the ends. Enter selects and deselects while the list stays open. What you took becomes a row of chips you can remove. That row is a single Tab stop, moved through with the left and right keys, and Delete removes one. Backspace in an empty field removes the last chip. Every change to what you typed is reported, so the choices can be fetched from your server instead.
- Apple
- Banana
- Cherry
- Grape
- Orange
- Melon
Keyboard
| Key | Action |
|---|---|
| Type | Filter options by substring and open the list. |
| ↓ / ↑ | Move the active option (wrapping). |
| Home / End | Move to the first / last visible option. |
| Enter | Toggle the active option (the list stays open). |
| Backspace (empty input) | Remove the last selected chip while retaining input focus. |
| Esc | Close the list. |
<%# Markup for the multi-select (multiple-selection combobox) demo.
Filter candidates as you type, select several, and show the selected ones as chips.
Focus stays on the input; the active candidate is shown via aria-activedescendant
and selection via aria-selected. The library handles filtering, multi-select
toggling, chip create/remove, roving, dismiss, and the selection announcements read
by the shared stimeo--announcer your app seats once, in its layout (selecting and
removing a value are the transitions worth reading; the multi-select owns no live
region of its own). Placement lives in demo.css. %>
<% announce_selected = t("components.multi_select.demo.announce_selected") %>
<% announce_removed = t("components.multi_select.demo.announce_removed") %>
<% remove_label = t("components.multi_select.demo.remove_label") %>
<div
class="multi-select"
data-controller="stimeo--multi-select"
data-stimeo--multi-select-announce-text-value="<%= announce_selected %>"
data-stimeo--multi-select-announce-removed-text-value="<%= announce_removed %>">
<span id="multi-select-label" class="multi-select__label">
<%= t("components.multi_select.demo.label") %>
</span>
<ul
class="multi-select__tags"
aria-label="<%= t("components.multi_select.demo.selected_label") %>"
data-stimeo--multi-select-target="tags"></ul>
<input
type="text"
class="multi-select__input"
role="combobox"
aria-expanded="false"
aria-autocomplete="list"
aria-controls="multi-select-list"
aria-labelledby="multi-select-label"
placeholder="<%= t("components.multi_select.demo.placeholder") %>"
data-stimeo--multi-select-target="input"
data-action="input->stimeo--multi-select#filter
keydown->stimeo--multi-select#onKeydown
focus->stimeo--multi-select#open" />
<ul
id="multi-select-list"
class="multi-select__list"
role="listbox"
aria-multiselectable="true"
aria-label="<%= t("components.multi_select.demo.options_label") %>"
hidden
data-stimeo--multi-select-target="list">
<% %w[apple banana cherry grape orange melon].each_with_index do |fruit, index| %>
<li
id="multi-select-opt-<%= index %>"
class="multi-select__option"
role="option"
aria-selected="false"
data-value="<%= fruit %>"
data-stimeo--multi-select-target="option"
data-action="click->stimeo--multi-select#toggleOption">
<%= t("components.multi_select.demo.options.#{fruit}") %>
</li>
<% end %>
</ul>
<template data-stimeo--multi-select-target="tagTemplate">
<li class="multi-select__tag" data-stimeo--multi-select-target="tag">
<span data-stimeo--multi-select-target="label"></span>
<%# Removal is handled by a delegated listener on the tags container, so it
works instantly without waiting on Stimulus wiring a data-action onto the
dynamically-added button. %>
<button
type="button"
class="multi-select__remove"
tabindex="-1"
aria-label="<%= remove_label %>"
data-stimeo--multi-select-target="remove">×</button>
</li>
</template>
</div>
/*
* Presentation-only styles for the multi-select demo.
* The library toggles the list's hidden, options' aria-selected, and the active
* candidate's data-active. Creating/removing chips and roving are the library's
* job too. The list's static placement (directly below the input) is the
* consumer's CSS; dynamic flip is via stimeo-ui/positioning.
*/
.multi-select {
position: relative;
display: inline-flex;
flex-direction: column;
gap: 0.35rem;
min-width: 18rem;
}
.multi-select__label {
font-size: 0.8125rem;
font-weight: 600;
color: var(--fg, var(--color-text));
}
.multi-select__tags {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin: 0;
padding: 0;
list-style: none;
}
.multi-select__tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.1rem 0.45rem;
border-radius: 999px;
background: var(--vital-100);
color: var(--vital-800);
font-size: 0.8125rem;
}
.multi-select__remove {
border: 0;
background: transparent;
color: inherit;
font: inherit;
line-height: 1;
cursor: pointer;
}
.multi-select__remove:focus-visible {
outline: 2px solid var(--accent, var(--color-primary));
outline-offset: 2px;
border-radius: 50%;
}
.multi-select__input {
padding: 0.5rem 0.65rem;
border: 1px solid var(--border-strong);
border-radius: 0.375rem;
background: var(--surface, var(--surface-card));
color: var(--fg, var(--color-text));
font: inherit;
}
.multi-select__input[aria-expanded="true"] {
border-color: var(--accent, var(--color-primary));
}
.multi-select__list {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 10;
max-height: 14rem;
overflow-y: auto;
margin: 0.25rem 0 0;
padding: 0.25rem;
list-style: none;
background: var(--surface, var(--surface-card));
border: 1px solid var(--border-strong);
border-radius: 0.375rem;
box-shadow: 0 8px 24px rgb(15 23 42 / 0.12);
}
.multi-select__option {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.45rem 0.6rem;
border-radius: 0.25rem;
cursor: pointer;
}
.multi-select__option[hidden] {
display: none;
}
/* A theme-aware soft accent surface, not the raw `--vital-100`: this rule sets a
background but lets the text inherit `--color-text`, which flips with the theme.
A fixed light mint therefore paired light text on light in dark mode — measured
1.02:1, i.e. the row the keyboard is on was effectively invisible. */
.multi-select__option[data-active] {
background: var(--color-primary-soft);
}
/* `--accent-800`, one step stronger than the plain accent text token: this text can
land on the tinted active/selected surface as well as the card, and on the tinted
one in dark `--accent-700` measures 4.44:1 — just under the floor. */
.multi-select__option[aria-selected="true"]::after {
content: "✓";
color: var(--accent-800);
}
.multi-select[data-stimeo--multi-select-empty] .multi-select__list::after {
content: "—";
display: block;
padding: 0.45rem 0.6rem;
color: var(--color-text-subtle);
}
This demo needs no consumer-side JS (the controller handles the behavior).
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--multi-select"
Targets
| Name | Description | Attribute |
|---|---|---|
input
required
|
The combobox text input driving filtering and tracking the active option via aria-activedescendant. |
data-stimeo--multi-select-target="input" |
list
required
|
The role=listbox (aria-multiselectable) popup, toggled via hidden. |
data-stimeo--multi-select-target="list" |
option
|
A role=option; filtered via hidden and toggled selected via aria-selected. |
data-stimeo--multi-select-target="option" |
tags
required
|
Container holding the selected-option chips, navigated as one roving Tab stop. | data-stimeo--multi-select-target="tags" |
tag
|
A removable chip mirroring one selected option. | data-stimeo--multi-select-target="tag" |
tagTemplate
|
<template> cloned to build each chip (label and remove button). |
data-stimeo--multi-select-target="tagTemplate" |
label
|
The template element that receives the selected option's display label. | data-stimeo--multi-select-target="label" |
remove
|
The remove button; its authored localized aria-label expands {label} and {value}. |
data-stimeo--multi-select-target="remove" |
fields
|
Optional container where selected values are mirrored as named hidden inputs. | data-stimeo--multi-select-target="fields" |
Values
| Name | Description | Attribute |
|---|---|---|
max
|
Permanent selection cap; non-positive values are unlimited and positive fractions are floored. | data-stimeo--multi-select-max-value |
name
|
Name for generated hidden inputs (default options[]). |
data-stimeo--multi-select-name-value |
form
|
Optional form id applied to generated hidden inputs. | data-stimeo--multi-select-form-value |
announceText
|
Localized selection template for the shared announcer; supports {label}, {value}, and {count}. |
data-stimeo--multi-select-announce-text-value |
announceRemovedText
|
Localized removal template for the shared announcer; supports the same placeholders. | data-stimeo--multi-select-announce-removed-text-value |
Actions
| Name | Description | Action |
|---|---|---|
close
|
Closes the list and clears the active option. | stimeo--multi-select#close |
filter
|
Filters options by the input substring, opens the list, re-seeds the active option, and dispatches filter. |
stimeo--multi-select#filter |
onKeydown
|
Handles input keyboard interaction (ArrowUp/Down wrapping, Home/End, Enter to toggle, Escape, Backspace/ArrowLeft into chips, Tab). | stimeo--multi-select#onKeydown |
open
|
Opens the list and activates the first visible option when none is active. | stimeo--multi-select#open |
toggleOption
|
Toggles the clicked option's selection, adding or removing its chip. | stimeo--multi-select#toggleOption |
Events
| Name | Description | Event |
|---|---|---|
change
|
Fires once when the reader adds or removes a selection; detail { values } (option order). |
stimeo--multi-select:change |
filter
|
Fires on filtering; detail { query } (the typed substring, for async candidates). |
stimeo--multi-select:filter |
reconcile
|
Fires when option churn or a lowered max moves the selection; same detail as change. | stimeo--multi-select:reconcile |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
aria-expanded |
Input | Open/closed state of the listbox. |
aria-activedescendant |
Input | The id of the active option (removed when none). |
aria-selected |
Option | "true" on selected options, "false" on the rest. Render the initial selection server-side and the chips and submitted values are derived from it. |
data-active |
Option | Present on the active option (a CSS highlight hook). |
data-stimeo--multi-select-empty |
Controller element | Present when the filter matches no options. |