Skip to main content
Copy for LLM

Combobox

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

Country Picker

With Preselected Value

Combobox with Initial Selection

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:

Combobox with disabled premium 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::select>. 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:

Combobox with grouped options

Size Variants

Three Sizes

Width

Like all WireKit form components, the combobox fills its container (w-full). Control the width via the parent element:

Combobox in a 24rem container
<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:

  • 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 combobox, 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. 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:

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

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.

Select without search

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::select> 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 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 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 InvalidArgumentException that names the option.

A language picker

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::combobox> 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.

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:

A combobox with nothing to choose
There are no variants yet. Add a variant

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.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 combobox — 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 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.

What a screen reader hears

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.

Error state

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. An autocomplete of yours does not apply there.
  • Accessible name (required): give every combobox a name. Use label for a visible label (associated with the input via for), hideLabel to keep that label for screen readers while hiding it visually, or ariaLabel to 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, ariaLabel or 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 via aria-controls
  • Each option: role="option", aria-selected
  • The chosen option shows what aria-selected says: 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 through aria-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-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), 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-describedby linking 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 Tab goes 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: Tab closes it, and no list stays behind over the page (see the keys below)
Labeled combobox

For a compact toolbar where a visible label would crowd the layout, keep the name for assistive tech with ariaLabel:

Toolbar combobox with an aria-label

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::select> is faster, simpler, and screen-reader-friendlier. Comboboxes shine when filtering matters.
  • Don't bind wire:model.live on the search input. Every keystroke round-trips. Use the component's built-in client-side filtering and bind wire:model to 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/wirekit.php under components.combobox. 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

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