Multi-Select
The <x-wirekit:: component is a combobox that supports selecting multiple values. Selected items appear as removable pills inside the input area, and the dropdown filters options as the user types. It implements the WAI-ARIA 1.2 Combobox pattern with aria-multiselectable="true".
Basic Usage
With Preselected Values
The value prop accepts an array of option keys that should be pre-selected on load.
Error State
With Hint
Assign one or more roles to this user.
Width
Like all WireKit form components, the multi-select fills its container (w-full). Control the width via the parent element:
<div class="max-w-md">
<x-wirekit::multi-select label="Tags" name="tags" :options="$tags" />
</div>
See Input — Width for more layout examples (grid columns, mixed widths).
Panel Placement and Width
The options panel opens below the field, aligned to its start, and exactly as wide as the field. Two props change that:
placementsets where the panel opens:bottom-start(the default),bottom,bottom-end,top-start,top,top-end,left-start,left,left-end,right-start,rightorright-end. When the chosen side has no room, the panel flips to the opposite one, and when aligning it to the field's end would push it off the screen, it aligns to the start instead.startandendfollow the reading direction: in a right-to-left document,bottom-endaligns the panel with the field's left edge.panel-widthsets how wide it is:trigger(the default) matches the field,autotakes the width of the widest option, and a CSS length such as20remsets one. A panel withautoor a length is never narrower than the field and never wider than the room its placement leaves.
A label wraps inside the panel rather than widening it. A name with no space or hyphen in it, the shape a repository name with underscores has, breaks inside the word where nothing else fits, so the list never has to scroll sideways.
A compact field with a wider panel suits options whose labels run longer than the field is wide:
Icons, Avatars and Descriptions
An option can show an icon, an image or an avatar before its label, and a line of description under it. The open list shows all of it. A pill shows the chosen option's icon, image or avatar and its label, never the description, and a shorter selectedLabel when the option has one. The pill's remove button keeps the full label as its name:
An avatar is a photo drawn round. When a person has no photo, or the photo fails to load, the avatar shows their initials instead, so a list of people stays aligned whether or not everyone has uploaded one:
The search matches an option's label, its selectedLabel and its keywords, ignoring case and accents (munchen finds "München"). It does not match the description: typing into the filter narrows the list to options whose name fits, and a sentence of context would match far more than the reader meant.
Options Format
The options prop accepts three formats, each normalized the way the Combobox normalizes it. (Combobox accepts a fourth, grouped shape; the multi-select does not group.)
{{-- Associative: key => label --}}
:options="['de' => 'Germany', 'fr' => 'France']"
{{-- List of strings (value === label) --}}
:options="['Apple', 'Banana', 'Cherry']"
{{-- List of arrays --}}
:options="[
['value' => 'de', 'label' => 'Germany'],
['value' => 'fr', 'label' => 'France'],
]"
A list is keys 0, 1, 2 … in order, and there each string is value and label at once. Any other array is a map whose key is submitted, integer keys included, so an id map from the database works as it reads:
{{-- An id map: the ids are submitted, not the names --}}
:options="Product::pluck('name', 'id')->all()"
A list you filtered keeps its keys (array_filter() leaves [0 => 'Apple', 2 => 'Cherry']) and is then read as a map, submitting 0 and 2. Pass it through array_values() to keep it a list.
An option written as an array can carry these keys beside value and label:
| Key | Type | Description |
|---|---|---|
icon |
string |
An icon name, the same names <x-wirekit::icon> takes |
image |
string |
An image URL, drawn square with a small radius |
avatar |
string|null |
A photo URL, drawn round. null still draws an avatar, showing initials |
initials |
string |
The initials an avatar shows without a photo. Derived from the label's first and last word when left out |
description |
string |
A line of plain text under the label, in the open list only |
keywords |
string|array |
More words the search matches |
selectedLabel |
string |
Shorter text for the pill once the option is chosen, such as a country code |
lang |
string |
The language the option's words are in, a tag such as de or pt-BR. The option, its pill and its entry among the chosen values carry it, so a screen reader says them in that language's voice |
An option shows one of icon, image and avatar. Setting two of them, or initials without avatar, throws an Invalid that names the option. So does a value that is not text, including an HtmlString: options are drawn in the browser as plain text, and markup would show up as literal tags.
A list of languages names each one in its own language, words the page is not written in. Give those options their lang:
Form Submission
Each selected value is submitted via a hidden <input type="hidden" name="name[]">. This means Laravel receives a standard array — no special parsing needed on the backend. Write name="skills" or name="skills[]", as for a native multiple select; both submit skills[].
Livewire Binding
Bind the array of selected values with wire:model on the component tag:
<x-wirekit::multi-select label="Tags" wire:model.live="tags" name="tags" :options="$options" :value="$tags" />
The selection lives in Alpine and is exposed to the binding, so the property receives the full array on every pick and every removal. Pass the current value with :value as well — that seeds the initial selection, the same framework-agnostic pattern the other stateful controls use.
An array of ids, such as $product->tags->pluck('id')->all(), works as it is: the ids reach the multi-select as numbers while its options carry their values as text, and each id names its option, so its pill shows the label and the list no longer offers it.
In an Alpine component of your own, x-model on the tag binds the same array. See An x-model on a WireKit component.
Server Search
For a list too long to render into the page, a catalog with thousands of records for example, set server and search in your Livewire component. The field stops filtering in the browser. What the reader types is sent as a search-change event once they pause, and the options you render are the results:
{{-- 1. The results are the options; the event hands the typed text to your component --}}
<x-wirekit::multi-select
server
label="Products"
name="productIds"
wire:model="productIds"
:options="$this->productResults['options']"
:truncated="$this->productResults['truncated']"
x-on:search-change="$wire.set('productSearch', $event.detail.value)"
/>
// 2. In the Livewire component
public string $productSearch = '';
public array $productIds = [];
#[Computed]
public function productResults(): array
{
// 3. Before a search: the records already chosen, so their pills have a name on load
if ($this->productSearch === '') {
return ['options' => Product::whereIn('id', $this->productIds)->pluck('name', 'id')->all(), 'truncated' => false];
}
// 4. A search: ask for one more than you show, and say so when it came back
$hits = Product::search($this->productSearch)->take(21)->get();
return ['options' => $hits->take(20)->pluck('name', 'id')->all(), 'truncated' => $hits->count() > 20];
}
What the field does with that:
- It sends only what is worth a query. The text goes out once it has at least
searchMinLengthcharacters (2 by default) and the reader has paused forsearchDebouncemilliseconds (300 by default), and only when it differs from the last search. Shorter text sends an empty value once, so your component can go back to what it lists before a search. - It says when it is waiting. From the moment the request carrying a search leaves until it comes back, with a response, an error or a cancellation, or is lost when the connection drops, the list is marked busy and says "Searching…".
$wire.set(…), as above, sends the request at once. A handler that holds it back leaves the list quiet after a second, because the wait has not begun. - A chosen record keeps its name. Every option the field has been given is remembered by its value, so a pill still reads "Red shirt" after a search for socks has replaced the list it was chosen from. A value the field was never given an option for shows as the value itself, which is why step 3 returns the chosen records.
- The search text stays after a pick, so several results of one search can be chosen in a row.
- An empty list says what it means: "Type to search" before any search, "Type at least 2 characters" for shorter text, "No results" for a search that found nothing, and, with
truncated, "More results. Keep typing to narrow them." under results cut at your limit. A polite live region reads the same status to a screen reader. - A failed search says so. A search your server answers with an error, or one lost when the connection drops, says "Search failed. Try again." while its text is in the field, instead of the results of an earlier search or "No results", and the same text is sent again the next time the reader types or opens the list.
Options in server mode may carry an icon, an image, an avatar or a description like any other; each row draws what its own option carries. The event is the one the data table sends in its server mode, with the same { value } payload.
Results as a List With Checkboxes
With layout="list" the options are not in a dropdown. They stand open under a search field, each
with a checkbox, and the choice is listed below them with a button to take each entry out. A chosen
option stays in the list with its box checked, so several can be picked from one search in a row.
No results
It works with Server Search unchanged: the text is sent as search-change, the
list says when it is searching and when your results were cut, and a chosen record keeps its name
in the list below after the results have moved on to another search.
{{-- 1. The same server search, shown as a list --}}
<x-wirekit::multi-select
server
layout="list"
label="Products"
name="productIds"
wire:model="productIds"
:options="$this->productResults['options']"
:truncated="$this->productResults['truncated']"
x-on:search-change="$wire.set('productSearch', $event.detail.value)"
/>
Optimistic UI
Pass the name of the Livewire method the control should call and the pill appears — or disappears — immediately, then confirms or undoes itself when the server answers:
<x-wirekit::multi-select
name="tags"
label="Tags"
:value="$tags"
:options="['php' => 'PHP', 'js' => 'JavaScript', 'css' => 'CSS']"
optimistic="saveTags"
/>
The method receives the full new selection as an array, not the value that changed:
public function saveTags(array $tags): void
{
$this->tags = $tags;
}
Load wirekit-optimistic. alongside whichever bundle you already use — below it, in your layout:
@wirekitScripts
<script src="{{ asset('vendor/wirekit/wirekit-optimistic.js') }}" defer></script>
Try it
The demo below runs the real path: the change shows immediately, the outline says it is provisional, and the server's answer either confirms it silently or takes it back.
The <livewire:demos.…> wrapper above exists only on this site — it supplies the demo
methods so the page can show a real round trip. The block under it is what you write.
The list stays open after a pick, so you can keep picking, and a pick made with a mouse leaves the focus in the filter input. Removing a pill takes the same path as picking an option: it is the same server mutation, so it is undone the same way. The filter text is separate state and is never rolled back — an undo puts back the server's selection, never what you were typing.
Picking or removing announces once, hedged — "Saving" — so the change is audible as provisional. Confirmation is silent: what was announced is what happened. Only a deviation speaks a second time, which is what makes an undo recognizable as an undo.
Where the control sits in a form that already shows a validation message, the undo stays silent and leaves that message to speak: it tells you what to do, "could not save" does not.
An aborted request announces nothing at all — nothing was refused.
Focus stays exactly where you put it. An undo arrives on the server's schedule, and moving focus then would take you out of your place for a reason you could not predict.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
required |
bool | false |
Marks the field as required. Emits aria-required="true" on the role="combobox" trigger and renders the label indicator (*). In a form, an empty required multi-select stops the submit as a native required field does: the browser's own message shows under the field, in the reader's language, and the focus moves to the field, with the list kept shut when it is the first empty field. |
label |
string|null |
null |
Label text above the input |
hint |
string|null |
null |
Help text below the input |
help |
string|null |
null |
An explanation of the field, shown in a tooltip from a question mark beside the label and read as the field's description. Needs a label to stand beside. See Help beside the label |
error |
string|null |
null |
Error message (also reads from $errors) |
name |
string|null |
null |
Form field name (submits as name[]) |
id |
string|null |
auto-generated | The text field's id. A <label for> of yours with this id names the field, and then no fallback name stands over it |
options |
array |
[] |
Options, see Options Format |
value |
array |
[] |
Initially selected values |
optimistic |
string|null |
null |
Livewire method to call, showing the new selection before the server confirms it. Receives the full selection as an array. See Optimistic UI. |
optimisticArgs |
array |
[] |
Extra arguments appended to the optimistic action call, after the new value — the row this control belongs to. |
placeholder |
string |
'Select…' |
Placeholder text in the filter input |
placement |
string |
'bottom-start' |
Where the options panel opens against the field; see Panel Placement and Width |
panelWidth |
string |
'trigger' |
'trigger' matches the field, 'auto' fits the widest option, or a CSS length such as '20rem' |
server |
bool |
false |
Search on the server: the typed text is sent as a search-change event and options are the results. See Server Search |
searchMinLength |
int |
2 |
With server, the characters typed before the text is sent |
searchDebounce |
int |
300 |
With server, the milliseconds of quiet before the text is sent. 0 sends at once |
truncated |
bool |
false |
With server, says the results were cut at your limit, and the list tells the reader to keep typing |
layout |
string |
'dropdown' |
'dropdown' opens the options in a panel and shows the choice as pills; 'list' shows them open under a search field with a checkbox each, and the choice listed below. See Results as a List With Checkboxes |
disabled |
bool |
false |
Disables the search field and the buttons that remove a choice, in both layouts, and a click on the field opens no list. Like a disabled native field, it is left out of the data a form submits |
ariaLabel |
string|null |
null |
Explicit aria-label for the internal combobox <input>. When unset, the component auto-derives a label from (in order) any passthrough aria-label attribute, then label, then placeholder, then name — so the internal input is never unlabeled. |
scope |
string|null |
null |
Scoped personalization key |
announceError |
bool|null | null |
Render the error message in an aria-live="polite" region so a validation error that appears after render reaches a screen reader. Precedence: this prop, then the parent <x-wirekit::form :announce-errors>, then config('wirekit.a11y.announce_error'). Set false when the page runs its own live region. |
Slots
| Slot | Description |
|---|---|
labelAfter |
Markup after the label and its help question mark, in the same row and outside both, such as a badge that says a setting is not decided yet. See A Mark After the Label. |
Accessibility
- The filter input carries
autocomplete="off", so the browser's own suggestions do not open over the list. Anautocompleteof yours does not apply there. - An
autofocus,inputmode,enterkeyhint,autocapitalizeorspellcheckwritten on the tag goes to the filter input, where the browser and the on-screen keyboard read it. - Filter input:
role="combobox",aria-expanded,aria-controls,aria-autocomplete="list",aria-multiselectable="true" - Listbox:
role="listbox"witharia-multiselectable="true"and unique id linked viaaria-controls - Each option:
role="option",aria-selected="true"/"false", and its own id - The open list holds the options not chosen yet, and each says
aria-selected="false". A choice leaves the list and shows as a pill in the field, where its remove button takes it back out. Withlayout="list"every option stays in the list, and its checkbox says whether it is chosen - An option with a description is named by its label through
aria-labelledbyand described by the description througharia-describedby, so a screen reader announces the two apart instead of as one long name - An option's icon, image or avatar is decorative (
aria-hidden), in the list and in the pills, because the label already names the option. An avatar's initials are not read either - Keyboard position:
aria-activedescendanton the filter input names the active option. Focus stays in the input for the whole interaction — that is what makes a combobox a combobox — so this attribute is what tells a screen reader which optionEnterwould take. The option is tinted on screen. Under forced colors, where that tint is not painted, the option the arrow keys are on is framed with a dashed line in the system's selection color - A press on an option with a mouse or a pen leaves the focus in the filter input, with and without
optimistic, so the nextTabgoes on from the field. The open list leaves with the focus:Tabcloses it, so two multi-selects in a row never leave two lists standing - Selected pills: each has a remove button with
aria-label="Remove {label}". Pressing it moves the focus to the pill that takes its place, to the new last pill when the last one was removed, or to the search field once none is left, so the nextTabgoes on from inside the field. The list of options stays closed through a removal, also when the search field takes the focus: it opens when the reader tabs or clicks into the field, types or presses an arrow key - Empty state: "No results" message when the filter matches nothing
- Error state:
aria-invalid="true"+aria-describedbylinking to error message - With
layout="list": a search field (type="search",aria-controlsnaming the results) instead of the combobox, the results as a group of native checkboxes named by the field's label, each checkbox named by its option, and a remove button per chosen entry named "Remove {label}". A checkbox says its own state, so no separate announcer speaks the pick, and taking an entry out moves the focus to the remove button that takes its place, or to the search field once the list is empty, withoptimisticset as well - Selection changes announced via an
aria-live="polite"region. Withoptimisticset, that region is not rendered and the optimistic layer announces the pick instead — one voice per change, so that hearing a second announcement always means something went differently
Keyboard Interaction
| Key | Action |
|---|---|
Tab |
Move focus to the filter input, which opens the listbox |
Enter / ArrowDown / ArrowUp |
Open the listbox after Escape closed it. ArrowDown opens on the first option, ArrowUp on the last |
ArrowDown / ArrowUp |
Move the active option. Stops at either end rather than wrapping |
Enter |
Toggle the active option's selection |
Home / End |
Jump to first / last option |
| Type to search | Filter options (case-insensitive substring match), and restart the active option at the top |
Backspace (in search field, empty input) |
Remove the last selected chip |
Escape |
Close the listbox |
Tab (in the filter input) |
Close the listbox and move to the next field |
With layout="list" there is no listbox to open: Tab moves from the search field through the checkboxes, Space checks or unchecks the focused one, and Tab goes on to the remove buttons of the chosen entries, which Enter or Space presses.
Space types a space. It is not bound to anything here, and deliberately so: this is an editable combobox rather than a select-only one, and a text field that swallows the space bar cannot be typed into. Use Enter to toggle.
Pitfalls
- Don't use multi-select for binary on/off groups. A row of
<x-wirekit::is more discoverable. Multi-select is for moderate-to-large option lists where space is at a premium.checkbox> - A list of plain strings submits the string itself, not a separate key. That is the point of the shape, and it is what you want for free-form tags. When the value your backend stores differs from the text a person reads, use the associative
key => labelmap or the array-of-arrays form instead — otherwise a renamed label silently changes the submitted value.
Design Tokens
| Token | Purpose |
|---|---|
--color-wk-bg-input |
Input area background |
--color-wk-bg-muted |
Selected pill background, and the option under the pointer or the arrow keys |
--color-wk-bg-elevated |
Dropdown panel background |
--color-wk-border-strong |
Default border color |
--color-wk-border-error |
Border color on error |
--color-wk-text |
Text color |
--color-wk-text-muted |
Placeholder and pill remove icon color |
--color-wk-accent |
The checked box with layout="list" |
--color-wk-accent-fg |
The tick in that box |
--color-wk-ring |
Focus ring color |
--radius-wk-md |
Border radius for input and dropdown |
--radius-wk-sm |
Border radius for pills |
--shadow-wk-lg |
Dropdown panel shadow |
--transition-wk-duration |
Open/close and highlight transitions |
Customization
Override defaults in config/:
'components' => [
'multi-select' => ['placeholder' => 'Choose...', 'placement' => 'bottom-start', 'panel-width' => 'trigger', 'search-min-length' => 2, 'search-debounce' => 300],
],
Further Reading
- WAI-ARIA 1.2 Combobox Pattern — the authoring pattern this component implements
- MDN:
role="combobox" - MDN:
role="listbox" - MDN:
aria-multiselectable - MDN:
aria-selected