---
title: Combobox
description: Autocomplete select with search filtering
visibility: guest
draft: false
---

# 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](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) with full keyboard navigation.

## Usage

:::preview{title="Country Picker"}
<x-wirekit::combobox
    name="country"
    label="Country"
    :options="[
        'de' => 'Germany',
        'fr' => 'France',
        'es' => 'Spain',
        'it' => 'Italy',
        'nl' => 'Netherlands',
        'be' => 'Belgium',
        'at' => 'Austria',
        'ch' => 'Switzerland',
    ]"
    placeholder="Search countries..."
/>
:::

## With Preselected Value

:::preview{title="Combobox with Initial Selection"}
<x-wirekit::combobox
    name="framework"
    label="Framework"
    value="laravel"
    :options="[
        'laravel' => 'Laravel',
        'symfony' => 'Symfony',
        'django' => 'Django',
        'rails' => 'Ruby on Rails',
        'express' => 'Express.js',
    ]"
/>
:::

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:

:::preview{title="Combobox with disabled premium options"}
<x-wirekit::combobox
    name="plan"
    label="Plan"
    placeholder="Select a plan..."
    :options="[
        ['value' => 'free', 'label' => 'Free — 5 projects'],
        ['value' => 'starter', 'label' => 'Starter — 25 projects'],
        ['value' => 'pro', 'label' => 'Pro — Unlimited (upgrade required)', 'disabled' => true],
        ['value' => 'enterprise', 'label' => 'Enterprise — Contact sales', 'disabled' => true],
    ]"
/>
:::

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>`](/components/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:

:::preview{title="Combobox with grouped options"}
<x-wirekit::combobox
    name="timezone"
    label="Timezone"
    placeholder="Search timezones..."
    :options="[
        'Europe' => ['cet' => 'Central European (CET)', 'gmt' => 'Greenwich Mean (GMT)', 'eet' => 'Eastern European (EET)'],
        'Americas' => ['est' => 'Eastern (EST)', 'pst' => 'Pacific (PST)'],
        'Asia' => ['jst' => 'Japan (JST)', 'ist' => 'India (IST)'],
    ]"
/>
:::

## Size Variants

:::preview{title="Three Sizes"}
<x-wirekit::combobox name="c-sm" label="Small" size="sm" :options="['a' => 'Apple', 'b' => 'Banana', 'c' => 'Cherry']" placeholder="Small..." />

<x-wirekit::combobox name="c-md" label="Medium" size="md" :options="['a' => 'Apple', 'b' => 'Banana', 'c' => 'Cherry']" placeholder="Medium..." />

<x-wirekit::combobox name="c-lg" label="Large" size="lg" :options="['a' => 'Apple', 'b' => 'Banana', 'c' => 'Cherry']" placeholder="Large..." />
:::

## Width

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

:::preview{title="Combobox in a 24rem container"}
<div style="max-width: 24rem;">
    <x-wirekit::combobox
        name="country-narrow"
        label="Country"
        placeholder="Search countries…"
        :options="['de' => 'Germany', 'fr' => 'France', 'es' => 'Spain', 'it' => 'Italy', 'nl' => 'Netherlands']"
    />
</div>
:::

:::source{language="blade"}
<x-wirekit::combobox
    name="country-narrow"
    label="Country"
    placeholder="Search countries…"
    :options="['de' => 'Germany', 'fr' => 'France', 'es' => 'Spain', 'it' => 'Italy', 'nl' => 'Netherlands']"
/>
:::

```blade
<div class="max-w-sm">
    <x-wirekit::combobox label="Country" name="country" :options="$countries" />
</div>
```

See [Input — Width](/components/input#width) for more layout examples (grid columns, mixed widths).

## Options Format

The `options` prop accepts four formats:

```blade
{{-- 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'],
]"
```

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

```blade
<x-wirekit::combobox 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.

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

```blade
<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:

```blade
@wirekitScripts
<script src="{{ asset('vendor/wirekit/wirekit-optimistic.js') }}"></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.

:::preview{title="Optimistic combobox — accepted, refused, and a slow answer"}
<livewire:demos.optimistic-host>
<x-wirekit::stack gap="lg" style="max-width: 26rem;">
    <x-wirekit::combobox name="opt-accept" label="Accepted — the confirmation is silent" value="de" :options="['de' => 'Germany', 'fr' => 'France', 'es' => 'Spain']" optimistic="demoAccept" />
    <x-wirekit::combobox name="opt-reject" label="Refused — the old choice comes back, and the refusal is spoken" value="de" :options="['de' => 'Germany', 'fr' => 'France', 'es' => 'Spain']" optimistic="demoReject" />
    <x-wirekit::combobox name="opt-slow" label="Slow answer — the dashed outline is the provisional state" value="de" :options="['de' => 'Germany', 'fr' => 'France', 'es' => 'Spain']" optimistic="demoSlow" />
</x-wirekit::stack>
</livewire:demos.optimistic-host>
:::

:::source{language="blade"}
{{-- In your app there is no host: your own Livewire component owns the method. --}}
<x-wirekit::combobox
    name="country"
    label="Country"
    :value="$country"
    :options="['de' => 'Germany', 'fr' => 'France', 'es' => 'Spain']"
    optimistic="saveCountry"
/>
:::

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

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

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `name` | `string\|null` | `null` | Form field name (submits the selected `value`) |
| `id` | `string\|null` | auto-generated | Element id |
| `options` | `array` | `[]` | Options (see Options Format below) |
| `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](#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'` |
| `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 |
| `disabled` | `bool` | `false` | Disabled state |
| `error` | `string\|null` | `null` | Error message (also reads from `$errors`) |
| `announceError` | `bool` | `true` | Announce the error in an `aria-live="polite"` region (defaults from `a11y.announce_error`). |
| `scope` | `string\|null` | `null` | Scoped personalization key |

## Accessibility

- **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.
- 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`
- Empty state: "No results" message when filter produces no matches
- Error state: `aria-invalid="true"` + `aria-describedby` linking to error message

:::preview{title="Labeled combobox"}
<x-wirekit::combobox name="country" label="Country" :options="['de' => 'Germany', 'fr' => 'France', 'it' => 'Italy']" placeholder="Search countries…" />
:::

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

:::preview{title="Toolbar combobox with an aria-label"}
<x-wirekit::combobox name="specialty" ariaLabel="Filter by specialty" :options="['cardio' => 'Cardiology', 'neuro' => 'Neurology']" placeholder="All specialties" />
:::

## Keyboard Interaction

| Key | Action |
|-----|--------|
| `Tab` | Move focus into the combobox input |
| `ArrowDown` / `ArrowUp` | Open the listbox and move the active option |
| `Enter` | Select the active option and close |
| 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 |

## 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-muted` / `--color-wk-bg-subtle` | Option hover + active 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:

```php
'components' => [
    'combobox' => ['size' => 'md', 'placeholder' => 'Type to search...'],
],
```

## Further Reading

- [WAI-ARIA 1.2 Combobox Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) — the authoring pattern this component implements
- [MDN: `role="combobox"`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/combobox_role)
- [MDN: `role="listbox"`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/listbox_role)
- [MDN: `aria-autocomplete`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-autocomplete)
- [MDN: `aria-expanded`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-expanded)
