Skip to main content
Copy for LLM

Multi-Select

The <x-wirekit::multi-select> 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

Multi-Select Countries

With Preselected Values

Preselected Tags

The value prop accepts an array of option keys that should be pre-selected on load.

Error State

Multi-select with an error message

Please select at least one tag.

With Hint

Hint Text

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:

  • placement sets where the panel opens: bottom-start (the default), bottom, bottom-end, top-start, top, top-end, left-start, left, left-end, right-start, right or right-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. start and end follow the reading direction: in a right-to-left document, bottom-end aligns the panel with the field's left edge.
  • panel-width sets how wide it is: trigger (the default) matches the field, auto takes the width of the widest option, and a CSS length such as 20rem sets one. A panel with auto or 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:

Compact multi-select, wide panel aligned to its end

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:

Icons and descriptions

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:

Avatars

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 InvalidArgumentException 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:

Languages, each in its own language

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.

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 searchMinLength characters (2 by default) and the reader has paused for searchDebounce milliseconds (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.

Options open under the search field

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

Optimistic multi-select — accepted, refused, and a slow answer

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.

What a screen reader hears

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. An autocomplete of yours does not apply there.
  • An autofocus, inputmode, enterkeyhint, autocapitalize or spellcheck written 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" with aria-multiselectable="true" and unique id linked via aria-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. With layout="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-labelledby and described by the description through aria-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-activedescendant on 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 option Enter would 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 next Tab goes on from the field. The open list leaves with the focus: Tab closes 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 next Tab goes 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-describedby linking to error message
  • With layout="list": a search field (type="search", aria-controls naming 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, with optimistic set as well
  • Selection changes announced via an aria-live="polite" region. With optimistic set, 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::checkbox> is more discoverable. Multi-select is for moderate-to-large option lists where space is at a premium.
  • 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 => label map 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/wirekit.php:

'components' => [
    'multi-select' => ['placeholder' => 'Choose...', 'placement' => 'bottom-start', 'panel-width' => 'trigger', 'search-min-length' => 2, 'search-debounce' => 300],
],

Further Reading

Updated in WireKit v2.66.0 (2026-10-13)

Was this page helpful?

Thank you for your feedback!

Voting requires cookies or local storage. What we store