Select
A dropdown select component with label, error handling, and placeholder support.
Basic Usage
Where the invoice is billed.
With Placeholder
With Error
Please select a role.
Errors from Laravel's validation bag are shown automatically when name matches a field, also for a nested name such as items[0][qty], whose error Laravel stores as items.0.qty, and for a list such as tags[].
With Success
Confirm a valid selection with a green border and optional message — the mirror of the error state, but valid (no aria-invalid). error wins when both are set.
Plan confirmed
Option Groups & Disabled Options
The :options array accepts three shapes you can mix freely. Nest an array under a label key to render an <optgroup>; give an option an array value with 'disabled' => true to disable it, or 'lang' => 'de' to mark which language that entry's text is in — see Language pickers.
<x-wirekit::select label="Destination" name="destination" :options="[
// A nested array under a label key becomes an optgroup with that label
'Europe' => ['ber' => 'Berlin', 'par' => 'Paris'],
// An option whose value is ['label' => ..., 'disabled' => true] is disabled
'Asia' => ['tyo' => 'Tokyo', 'sin' => ['label' => 'Singapore (sold out)', 'disabled' => true]],
]" />
Slot Options
For more control, use the default slot instead of the :options prop:
Nothing to Choose
A select filled from your records has no option until the first record exists, and a placeholder alone tells the reader neither what is missing nor where to add it. empty-text says what is missing, and empty-href links to the screen that creates it. While the select has no option, it is disabled and the sentence below it is its description, so a screen reader on the field hears why it is empty:
The three props only take effect while there is nothing to choose. As soon as options holds an entry, or an <option> arrives through the slot, the select renders as it always does. Pass the records straight in and let the select decide:
<x-wirekit::select
label="Supplier"
name="supplier_id"
wire:model="supplierId"
placeholder="Choose a supplier"
:options="$suppliers->pluck('name', 'id')"
empty-text="There are no suppliers yet."
:empty-href="auth()->user()->can('create', Supplier::class) ? route('suppliers.create') : null"
empty-action="Add a supplier"
/>
Name what the link creates in empty-action. Without it the link reads "Create one". Leave empty-href null for a reader who may not create the record: the sentence still explains the empty field, and no link leads to a page that would refuse them.
Bound to a property declared int, float or an enum backed by int, such as the id of a record or a priority, the select is read as a number: wire:model goes out as wire:model.. A select gives its value as text, and Livewire would take the server's typed answer for a change made on the server and set the select back to it while a newer choice is on its way. A property without a type, or a string one, gets the text.
In an application that moves between its screens with wire:navigate, add empty-navigate and the link does the same instead of reloading the page. A link to a fragment of the same page keeps the browser's own jump.
For a control of your own, such as a button that opens a dialog to add the record in place, the empty slot replaces the sentence and the link. In your application options comes from your records, as above:
Width
Like all WireKit form components, the select fills its container (w-full). Control the width via the parent element:
<div class="max-w-xs">
<x-wirekit::select label="Country" name="country" :options="$countries" />
</div>
See Input — Width for more layout examples (grid columns, mixed widths).
On Mobile
Because this component is a real <select>, the open dropdown is drawn by the operating system, not by WireKit — so on a phone or tablet it appears as the platform's native picker (the iOS wheel sheet, the Android dropdown). That is deliberate: the native picker is the most accessible, most familiar, and most touch-friendly option list a mobile user can get, and it needs no extra JavaScript.
One thing to know when testing: a desktop browser's device-emulation mode (e.g. DevTools' responsive view) draws that OS popup against the real window rather than the emulated viewport, so the option list can look small or mis-placed there. That is an artifact of the emulator — on a real device the picker opens correctly. Test native pickers on an actual phone, or trust the emulator only for the trigger, not the OS popup.
If you instead need a dropdown that renders consistently across every device and is fully themeable — with search, custom option markup, or multi-value selection — reach for <x-wirekit:: (single value, client-side filtering) or <x-wirekit:: (multiple values). Both are custom listboxes built on Alpine + Floating UI, so their open panel is WireKit-styled and positions identically everywhere.
Optimistic UI
Pass the name of the Livewire method this component should call and the change appears immediately, then confirms or undoes itself when the server answers:
<x-wirekit::select
label="Plan"
name="plan"
:value="$plan"
optimistic="choosePlan"
>
<option value="free">Free</option>
<option value="pro">Pro</option>
</x-wirekit::select>
Load wirekit-optimistic. alongside whichever bundle you already use — it is a separate file so applications that do not use it pay nothing for it:
@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.
A second change while one is in flight is refused rather than queued, and that matters more here than on a toggle: queued, the final selection would be whichever answer arrived last, which is network timing.
What a screen reader hears is the same for every component that supports this, and it is written out in full — one hedged announcement at the change, silence on confirmation, one more only if the server refuses, and focus that never moves — in Optimistic UI — the announcement contract. That page is also where the boundary is stated: this covers mutations, not sorting, filtering or pagination.
Language pickers
A language picker lists endonyms — Deutsch, Español, Français — so most of its labels are
words in a language the page is not written in. Without a marker a screen reader pronounces all of
them in the page's voice, and this is the one control that exists for the people who would notice.
Give each entry a lang:
{{-- 1. The key is the value submitted; 'label' is what the reader sees. --}}
{{-- 2. 'lang' is the BCP 47 tag for the label's own language, not for the page. --}}
<x-wirekit::select
label="Language"
name="locale"
:options="[
'de' => ['label' => 'Deutsch', 'lang' => 'de'],
'es' => ['label' => 'Español', 'lang' => 'es'],
'fr' => ['label' => 'Français', 'lang' => 'fr'],
]"
/>
This is WCAG 2.1 AA 3.1.2 (Language of Parts). The proper-name exception does not cover it: an endonym is the language's own word for itself, not a borrowed one.
Leave flags out of a language picker. A flag names a country, and a language is rarely one country's: Spanish is spoken as a first language across many countries, Portuguese belongs to Brazil as much as to Portugal, and English sits under several flags at once. Any single flag misnames the language and tells some of its speakers they are guests, while the endonym says exactly what it means.
axe's valid-lang rule carries the wcag312 tag, but it validates a lang attribute that is
present. With none in the document there are no nodes to judge and the scan reports zero
violations — so a picker with no lang anywhere passes an audit and silently mispronounces every
entry. Do not read a green accessibility run as an answer to this question; you must check the
markup.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
optimistic |
string|null |
null |
Livewire method to call, showing the new value before the server confirms it. See Optimistic UI. |
optimisticArgs |
array |
[] |
Extra arguments appended to the optimistic action call, after the new value — the row this control belongs to. |
label |
string|null | null |
Label text above the select |
hideLabel |
bool | false |
Render the label visually hidden (sr-only) but keep it for assistive tech — for a compact field in a toolbar / header |
hint |
string|null | null |
Help text below the select |
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 |
reserveMessage |
bool | false |
Keep the message line's height even when there is no message — see Fields in a row |
error |
string|null | null |
Error message (overrides $errors bag) |
success |
string|bool|null | null |
Valid-state confirmation — string shows a green message, true shows just the green border. error wins. |
size |
string | 'md' |
sm, md-compact, md, lg. md-compact is the 2.25rem tier between sm and md, for dense toolbars — see Input for the worked example |
placeholder |
string|null | null |
Placeholder option text |
value |
string|null | null |
Pre-selected option key. Compared as a string, so 1 and '1' both match |
options |
array | [] |
Options. Flat (['de' => 'Germany']), grouped (['Europe' => ['de' => 'Germany']] → <optgroup>), or per-option attrs (['de' => ['label' => 'Germany', 'disabled' => true, 'lang' => 'de']]). Mix freely. |
emptyText |
string|null | null |
What the field says while it has no option: no entry in options and none in the slot. The select 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 |
scope |
string|null | null |
Scoped personalization name |
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 |
|---|---|
| Default | <option> elements of your own, after the ones built from options. |
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. |
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
- Built on the native
<select>element — full browser keyboard support out of the box - Label is a proper
<label for="...">matched to the select'sid - Error messages linked via
aria-describedbyandaria-invalid="true" - Hint text linked via
aria-describedby - With nothing to choose, the select is disabled and names the
empty-textline inaria-describedby. The link sits outside the select, so it stays in the tab order - Native
<select>supports arrow keys, Home/End, type-to-search, and screen reader announcements by default
Keyboard Interaction
| Key | Action |
|---|---|
Tab |
Move focus to the select |
Space / Enter / ArrowDown |
Open the option list |
ArrowUp / ArrowDown |
Move between options |
Home / End |
Jump to first / last option |
| Type a character | Type-ahead — focus next option whose label starts with that character |
Escape |
Close the option list without changing the value |
Pitfalls
- Don't pass a plain list to
:options. The array KEY is what the option submits, so['Germany', 'Austria']rendersvalue="0"/value="1"— the labels read correctly and the form submits array indexes. A list of['value' => …, 'label' => …]entries does the same thing for the same reason: its keys are still0and1. Pass an associative array (['de' => 'Germany']), a grouped array, or per-option attrs — see Option Groups & Disabled Options. - Don't use
wire:model.livefor huge option lists. Each keystroke during type-ahead would round-trip to the server. Usewire:model.bluror convert to<x-wirekit::with client-side filtering.combobox>
Design Tokens
| Token | Used for |
|---|---|
--font-wk-sans |
Body font family |
--font-wk-letter-spacing |
Letter spacing |
--text-wk-sm / --text-wk-md / --text-wk-lg |
Font size per size prop |
--color-wk-text |
Selected option text |
--color-wk-text-muted / --color-wk-text-subtle |
Placeholder + chevron |
--color-wk-bg-input |
Select background |
--color-wk-border-strong |
Default border |
--color-wk-border-strong-hover |
Hover border |
--color-wk-border-error |
Error-state border |
--color-wk-danger / --color-wk-danger-text |
Error message |
--color-wk-ring / --color-wk-ring-offset |
Focus ring color + offset |
--ring-wk-width / --ring-wk-offset |
Focus ring geometry |
--border-wk-width |
Border width |
--radius-wk-sm / --radius-wk-md |
Border radius |
--shadow-wk-sm |
Subtle shadow |
--size-wk-sm / --size-wk-md-compact / --size-wk-md / --size-wk-lg |
Control height per size prop |
--padding-wk-x-sm / --padding-wk-x-md / --padding-wk-x-lg |
Horizontal padding |
--opacity-wk-disabled |
Disabled visual weight |
--transition-wk-duration / --transition-wk-easing |
Hover / focus transition |
Config Defaults
The defaults live in config/ under components.. Override them globally:
'components' => [
'select' => ['size' => 'lg'], // change default size
],
See Also
- Inline Edit — edit this value in place, with an explicit confirm step
Further Reading
- MDN:
<select>element - MDN:
<option>element - MDN: Styling
<select> - WebAIM: Creating Accessible Forms — form accessibility primer