Combobox
The <x-wirekit:: component is a searchable select that filters options as the user types. It implements the WAI-ARIA 1.2 Combobox pattern with full keyboard navigation. The filter ignores case and accents, so typing munchen finds "München" and istanbul finds "İstanbul".
Usage
With Preselected Value
The value prop matches an option's value key — the label is looked up and pre-filled into the search input on load.
Disabled Options
Pass disabled => true on an option to render it dimmed and non-interactive — keyboard arrow-navigation skips it, mouse hover doesn't highlight it, click + Enter ignore it. Useful for premium-tier features, "coming soon" entries, or context-locked options:
The aria-disabled="true" attribute on disabled options keeps screen readers correctly informed.
Grouped Options
Organize options under headings by passing a nested array — a group is an array value keyed by its group label, mirroring <x-wirekit::. Each group renders as a role="group" with an accessible label; filtering hides empty groups automatically, and arrow-key navigation flows across the group boundaries as one continuous list:
Size Variants
Width
Like all WireKit form components, the combobox fills its container (w-full). Control the width via the parent element:
<div class="max-w-sm">
<x-wirekit::combobox label="Country" name="country" :options="$countries" />
</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. The field shows the chosen option's icon, image or avatar and its label, never the description, and a shorter selectedLabel when the option has one:
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. It does not match the description: typing into the field narrows the list to options whose name fits, and a sentence of context would match far more than the reader meant.
Select Without Search
For a short list nobody needs to search, such as a priority, a role or a visibility setting, set :searchable="false". The field becomes a select-only combobox: it opens like a select, takes no typed text, and still shows icons, avatars and descriptions in its options.
Typing still finds an option: a letter moves to the first option that starts with it, the same letter again moves to the next one, and a few letters typed quickly move to the option that starts with all of them. The form value, wire:model and optimistic work exactly as they do on a searchable combobox.
When the options are plain text and the field is mostly used on phones, the native <x-wirekit:: is still the better choice: its list is the operating system's own picker, which a phone user already knows how to use (see On Mobile). Reach for the select-only combobox when the options need an icon, an avatar or a description, or when the open list has to look the same on every device.
Options Format
The options prop accepts four formats:
{{-- 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'],
]"
{{-- Grouped: group label => sub-options (any of the shapes above) --}}
:options="[
'Europe' => ['de' => 'Germany', 'fr' => 'France'],
'Asia' => ['jp' => 'Japan'],
]"
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 |
|---|---|---|
disabled |
bool |
Shows the option dimmed and skips it with the arrow keys |
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 field 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. A screen reader says the option 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 language picker
A list of languages names each one in its own language: "Deutsch", "Français", "日本語". Those are words in a language the page is not in, so give each option its lang, or a screen reader reads them in the page's voice. A lang that is not a language tag throws an Invalid that names the option.
Form Submission
The selected value (not label) is submitted via a hidden <input type="hidden"> with the given name. The visible text input is for search + display only.
Livewire Integration
<x-wirekit:: keeps its selected value in internal state seeded from
the value prop, so when binding with Livewire pass the bound property as
:value alongside wire:model to seed the initial selection:
<x-wirekit::combobox label="Country" wire:model.live="country" :value="$country" :options="$countries" />
Without :value the combobox shows its placeholder until the user picks an
option; wire:model keeps it in sync afterward. This is the framework-agnostic
seeding pattern WireKit's stateful controls share — it works in plain Blade
forms too.
A property that holds an id, such as public ?int $countryId, works as it is: the id
reaches the combobox as a number while its options carry their values as text, and
the two name the same option, at the start and after every answer of the server.
In an Alpine component of your own, x-model on the tag binds the selected value the same way. See An x-model on a WireKit component.
Server Search
For a list too long to render into the page, 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::combobox
server
label="Product"
name="productId"
wire:model="productId"
:value="$productId"
: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 ?int $productId = null;
#[Computed]
public function productResults(): array
{
// 3. Before a search: the record already chosen, so the field shows its name on load
if ($this->productSearch === '') {
return ['options' => Product::whereKey($this->productId)->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];
}
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. The name of the choice the field shows is not a search: choosing, clearing, or a choice set by your component ends a search with an empty value, so your component goes back to what it lists before one, and the field keeps the choice's name after the list has moved on.
While a search is out, from the moment its request leaves until it comes back, the list is marked busy and says "Searching…". An empty list says what it means: "Type to search", "Type at least 2 characters" or "No results", and with truncated the results end in "More results. Keep typing to narrow them." 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, and the same text is sent again the next time the reader types or opens the list. A polite live region reads the same status to a screen reader. Each row draws the icon, image, avatar or description its own option carries.
server needs the text field, so it does nothing with searchable off. Multi Select takes the same props and sends the same event.
Nothing to Choose
A combobox filled from your records has no option until the first record exists. As on select, empty-text says what is missing and empty-href links to the screen that creates it, empty-action names the link and empty-navigate moves there with wire:navigate. While there is nothing to choose, the combobox is disabled, the sentence takes the hint's place below it, and the field names the sentence in aria-describedby, so a screen reader on the field hears why it is empty:
With options, the combobox decides by itself: the props take effect while options holds no entry, and it renders as it always does once there is one. A server search starts every list empty, so there your component says it with nothing-to-choose:
<x-wirekit::combobox
server
label="Variant"
name="variantId"
wire:model="variantId"
:options="$this->variantResults['options']"
:nothing-to-choose="! $this->anyVariant"
empty-text="There are no variants yet."
:empty-href="route('variants.create')"
empty-action="Add a variant"
x-on:search-change="$wire.set('variantSearch', $event.detail.value)"
/>
The empty slot replaces the sentence and the link, for a control of your own. The panel below the field carries data-wk-combobox-empty.
Optimistic UI
Pass the name of the Livewire method the combobox should call and the choice lands immediately, then confirms or undoes itself when the server answers:
<x-wirekit::combobox
name="country"
label="Country"
:value="$country"
:options="['de' => 'Germany', 'fr' => 'France', 'es' => 'Spain']"
optimistic="saveCountry"
/>
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 prop takes a method name rather than true because the component cannot know the action otherwise: server actions reach a WireKit component through the attribute bag, so it never sees your wire:click. optimistic replaces wire:model.live here rather than joining it — the method you name is what changes the value on the server; pass the current one with :value so the combobox knows where it started.
All three ways of choosing take the same path: clicking an option, pressing Enter on the highlighted one, and the clear button where clearable leaves it in place. Clearing calls your method with null — choosing nothing is still a choice, and the server has to hear about it. Pressing Enter with nothing highlighted sends nothing at all.
After an undo the text field shows the label of the previous selection, not the one that was refused. The field follows the value rather than the click, which is what makes that possible.
Choosing announces once, hedged — "Saving" — so the new value 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 combobox 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.
Pick the country the invoice is billed to.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
hint |
string|null |
null |
Helper text below the control, wired with aria-describedby. An error supersedes it — the reader is never pointed at two messages at once. |
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 |
name |
string|null |
null |
Form field name (submits the selected value) |
id |
string|null |
auto-generated | Element id |
options |
array |
[] |
Options, see Options Format |
value |
string|null |
null |
Initial selected value |
optimistic |
string|null |
null |
Livewire method to call, showing the new choice before the server confirms it. Ignored when disabled. See Optimistic UI. |
optimisticArgs |
array |
[] |
Extra arguments appended to the optimistic action call, after the new value — the row this control belongs to. |
size |
string |
'md' |
'sm', 'md', 'lg' |
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' |
searchable |
bool |
true |
false renders a select-only combobox without a text field; see Select Without Search |
server |
bool |
false |
Search on the server: the typed text is sent as a search-change event and options are the results. Needs searchable. 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 |
emptyText |
string|null |
null |
What the field says while there is nothing to choose: no entry in options, or nothingToChoose. The combobox is then disabled and the sentence is its description. See Nothing to Choose |
emptyHref |
string|null |
null |
Where the missing record is created. Renders a link after emptyText |
emptyAction |
string|null |
null |
The link's text, naming what it creates. Without it the link reads "Create one" |
emptyNavigate |
bool |
false |
Draw the link with wire:navigate, so it changes screens without reloading the page. Never on a link to a fragment of the same page |
nothingToChoose |
bool |
false |
Says there is nothing to choose, for a server search, whose list starts empty before every search |
clearable |
bool |
true |
The clear button hides with the value it clears and hands the focus to the field, with the list left shut. false hides the clear button. Turn it off when the value is a precondition for the field around it — a country whose dialing code assembles a phone number, for instance, where clearing leaves a number on screen and an empty string in the form |
placeholder |
string |
'Select…' |
Placeholder text |
label |
string|null |
null |
Visible label, associated with the input via for |
hideLabel |
bool |
false |
Keep the label in the DOM for assistive tech but hide it visually (compact toolbar / header fields) |
ariaLabel |
string|null |
null |
Accessible name applied directly to the input when there is no visible label |
required |
bool |
false |
Marks the field as required: it carries aria-required="true" and the label shows its indicator (*). What counts is the choice, never text typed into the field that matches no option. In a form, an empty required combobox 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. |
disabled |
bool |
false |
Disables the field and its list button. Like a disabled native field, it is left out of the data a form submits |
error |
string|null |
null |
Error message (also reads from $errors) |
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. |
scope |
string|null |
null |
Scoped personalization key |
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. |
empty |
A control of your own in place of the sentence and the link shown while there is nothing to choose. See Nothing to Choose. |
Accessibility
- The search field carries
autocomplete="off", so the browser's own suggestions do not open over the list. Anautocompleteof yours does not apply there. - Accessible name (required): give every combobox a name. Use
labelfor a visible label (associated with the input viafor),hideLabelto keep that label for screen readers while hiding it visually, orariaLabelto name the input directly when the surrounding UI already makes its purpose clear (e.g. a compact facet toolbar). Without one, the control fails WCAG 4.1.2. - The clear button is named after the field it empties, "Clear Country" for a field
labeled Country. The name comes from
label,ariaLabelor the label of the field around the combobox; a combobox with none of them reads "Clear selection". - Text input:
role="combobox",aria-expanded,aria-controls,aria-autocomplete="list" - Listbox:
role="listbox"with unique id linked viaaria-controls - Each option:
role="option",aria-selected - The chosen option shows what
aria-selectedsays: a check at the end of its row, drawn in the text color, so it holds in grayscale and under forced colors. The tinted row is the one the arrow keys or the pointer are on, which is a different thing. 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 - Without search, the field is a focusable element with
role="combobox"instead of an input, named by its label througharia-labelledby. Its text is the current choice, which a screen reader reads as the combobox's value - 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), because the label already names the option. An avatar's initials are not read either - Empty state: "No results" message when the filter produces no matches, spoken through a polite live region that is present from the first render, so it is announced without focus moving (WCAG 4.1.3)
- Error state:
aria-invalid="true"+aria-describedbylinking to error message - Focus stays in the input for the whole interaction. A choice made with a mouse or a pen leaves it there, so the next
Tabgoes on from the field; a choice made with a finger hands it to the page, which closes the on-screen keyboard over the choice - The open list leaves with the focus:
Tabcloses it, and no list stays behind over the page (see the keys below)
For a compact toolbar where a visible label would crowd the layout, keep the
name for assistive tech with ariaLabel:
Keyboard Interaction
| Key | Action |
|---|---|
Tab |
Move focus into the combobox input |
ArrowDown / ArrowUp |
Open the listbox and move the active option |
Enter |
On an open list, select the active option and close; on a closed list, open it at the current choice |
| Type a character | Filter options (case-insensitive substring match) |
Home / End |
Move the active option to first / last |
Escape |
Close the listbox without changing the value |
Tab (in the input) |
Close the listbox without choosing and move to the next field |
Without search (:searchable="false"), focus stays on the field and the keys follow the WAI-ARIA select-only combobox pattern. Disabled options are skipped by every key.
| Key | List closed | List open |
|---|---|---|
ArrowDown / Alt+ArrowDown |
Open at the current choice | Move to the next option |
ArrowUp |
Open at the current choice | Move to the previous option |
Alt+ArrowUp |
— | Choose the active option and close |
Enter / Space |
Open at the current choice | Choose the active option and close |
Home / End |
Open at the first / last option | Move to the first / last option |
PageUp / PageDown |
— | Move ten options up / down |
Tab |
Move to the next field | Choose the active option, close, and move to the next field |
Escape |
— | Close without changing the choice |
| Type a character | Open at the first option that starts with it | Move to the next option that starts with what was typed |
While a search is being typed, Space joins it, so an option such as "New York" can be typed in full. Clicking the field opens and closes the list; it does not open when focus arrives by Tab.
Pitfalls
- Don't use combobox for a known-small option list (≤6 items). A native
<x-wirekit::is faster, simpler, and screen-reader-friendlier. Comboboxes shine when filtering matters.select> - Don't bind
wire:model.liveon the search input. Every keystroke round-trips. Use the component's built-in client-side filtering and bindwire:modelto the selected value only.
Design Tokens
| Token | Used for |
|---|---|
--text-wk-xs / --text-wk-sm / --text-wk-md / --text-wk-lg |
Font size per size prop |
--color-wk-text |
Selected option text |
--color-wk-text-muted |
Empty-state + chevron text |
--color-wk-text-placeholder |
Placeholder text |
--color-wk-bg-input |
Trigger background |
--color-wk-bg-elevated |
Listbox panel background |
--color-wk-bg-active |
Option hover and the option under the arrow keys |
--color-wk-bg-hover |
Clear-button hover background |
--color-wk-accent |
Active highlight + selected check |
--color-wk-border-strong |
Default border |
--color-wk-border-error |
Error-state border |
--color-wk-danger-text |
Error message |
--color-wk-ring |
Focus ring |
--ring-wk-width |
Focus ring width |
--border-wk-width |
Border width |
--radius-wk-sm / --radius-wk-md |
Border radius |
--shadow-wk-md |
Listbox panel shadow |
--size-wk-sm / --size-wk-md / --size-wk-lg |
Trigger height per size prop |
--padding-wk-x-md / --padding-wk-y-xs / --padding-wk-y-sm |
Trigger + option padding |
--opacity-wk-disabled |
Disabled visual weight |
--transition-wk-duration |
Hover / focus transition |
--z-wk-dropdown |
Listbox stacking context |
Config Defaults
The defaults live in config/ under components.. Override them globally:
'components' => [
'combobox' => ['size' => 'md', 'placeholder' => 'Type to search...', '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-autocomplete - MDN:
aria-expanded