Select
Pick a value from a list — the native select, a custom listbox, a filterable combobox or a scrolling wheel.
Native Select — A native select control with a custom chevron and an optional leading icon. Inside a <x-ui.field name> (or with its own name) it self-wires name/id and aria-invalid/aria-describedby from the shared $errors bag.
Preview
<x-ui.select aria-label="{{ __('Country') }}" class="max-w-sm">
<option>{{ __('United States') }}</option>
<option>{{ __('Netherlands') }}</option>
<option>{{ __('Japan') }}</option>
</x-ui.select>
Installation
php artisan ui:add select
Registry contract
php artisan ui:add select
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/select.blade.php
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Native Select (`select`)
A native select control with a custom chevron and an optional leading icon. Inside a <x-ui.field name> (or with its own name) it self-wires name/id and aria-invalid/aria-describedby from the shared $errors bag.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add select
```
## Usage
```blade
<x-ui.select aria-label="{{ __('Country') }}" class="max-w-sm">
<option>{{ __('United States') }}</option>
<option>{{ __('Netherlands') }}</option>
<option>{{ __('Japan') }}</option>
</x-ui.select>
```
## Props
- `size` (sm|default|lg, default `md`) — Control height and text size.
- `invalid` (bool|null, default `false`) — Overrides validation-derived invalid state.
- `disabled` (bool, default `false`) — Declared by @props in the shipped Blade source.
- `icon` (mixed|null, default `null`) — Declared by @props in the shipped Blade source.
## Use when
- Use when users choose among a bounded set and space efficiency matters more than always-visible comparison.
- Selecting one value from a compact list where native platform behavior is desirable.
- A server-submitted form needs resilient no-JavaScript behavior.
## Avoid when
- Do not use for tiny lists or when users benefit from seeing all options at once.
- Use to save space with longer lists; do not hide tiny sets that deserve direct comparison.
- Options need search, rich metadata, or custom multi-select behavior; use select-advanced or combobox.
- There are only two to five choices that should remain visible; use radio or choicebox.
## Anti-patterns
- Hiding tiny or high-frequency option sets
## Rules
- Use the `<brok:select>` tag (or `<x-ui.select>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/select
- Registry JSON (files, props, contract): https://brokui.dev/r/open/select.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
<x-ui.select class="max-w-sm" aria-label="{{ __('Choose a city') }}">
<optgroup label="{{ __('Europe') }}">
<option>{{ __('Amsterdam') }}</option>
<option>{{ __('Berlin') }}</option>
<option>{{ __('Madrid') }}</option>
</optgroup>
<optgroup label="{{ __('Asia') }}">
<option>{{ __('Tokyo') }}</option>
<option>{{ __('Singapore') }}</option>
</optgroup>
</x-ui.select>
<form method="POST" action="#" class="w-full max-w-sm space-y-4">
@csrf
<x-ui.field>
<x-ui.label for="select-plan" :required="true">{{ __('Plan') }}</x-ui.label>
<x-ui.select id="select-plan" name="plan" required aria-describedby="select-plan-hint">
<option value="" disabled selected>{{ __('Select a plan…') }}</option>
<option value="free">{{ __('Free') }}</option>
<option value="pro">{{ __('Pro') }}</option>
<option value="enterprise">{{ __('Enterprise') }}</option>
</x-ui.select>
<p id="select-plan-hint" data-slot="field-description" class="text-sm text-muted-foreground">
{{ __('You can change this later in billing settings.') }}
</p>
</x-ui.field>
<x-ui.button type="submit">{{ __('Save') }}</x-ui.button>
</form>
Placeholder
<x-ui.select class="max-w-sm" aria-label="{{ __('Role') }}">
{{-- A disabled, selected, empty-value option acts as a non-selectable placeholder. --}}
<option value="" disabled selected>{{ __('Select a role…') }}</option>
<option value="owner">{{ __('Owner') }}</option>
<option value="admin">{{ __('Admin') }}</option>
<option value="member">{{ __('Member') }}</option>
</x-ui.select>
<div class="flex w-full max-w-sm flex-col gap-3">
<x-ui.select size="sm" aria-label="{{ __('Small select') }}">
<option>{{ __('Small') }}</option>
<option>{{ __('Another option') }}</option>
</x-ui.select>
<x-ui.select size="md" aria-label="{{ __('Medium select') }}">
<option>{{ __('Medium') }}</option>
<option>{{ __('Another option') }}</option>
</x-ui.select>
<x-ui.select size="lg" aria-label="{{ __('Large select') }}">
<option>{{ __('Large') }}</option>
<option>{{ __('Another option') }}</option>
</x-ui.select>
</div>
<div class="flex w-full max-w-sm flex-col gap-3">
<x-ui.select :invalid="true" aria-label="{{ __('Invalid select') }}">
<option>{{ __('Choose a plan') }}</option>
<option>{{ __('Free') }}</option>
<option>{{ __('Pro') }}</option>
</x-ui.select>
<x-ui.select :disabled="true" aria-label="{{ __('Disabled select') }}">
<option>{{ __('Disabled') }}</option>
</x-ui.select>
{{-- Per-option disabling uses the native `disabled` attribute. --}}
<x-ui.select aria-label="{{ __('Select with disabled items') }}">
<option>{{ __('Available') }}</option>
<option disabled>{{ __('Sold out') }}</option>
<option>{{ __('Available') }}</option>
</x-ui.select>
</div>
With Description
<x-ui.field class="w-full max-w-sm">
<x-ui.label for="select-timezone">{{ __('Timezone') }}</x-ui.label>
<x-ui.select id="select-timezone" name="timezone" aria-describedby="select-timezone-hint">
<option>{{ __('UTC') }}</option>
<option>{{ __('Europe/Amsterdam') }}</option>
<option>{{ __('America/New_York') }}</option>
</x-ui.select>
<p id="select-timezone-hint" data-slot="field-description" class="text-sm text-muted-foreground">
{{ __('Used for scheduling and notifications.') }}
</p>
</x-ui.field>
With Icon
<x-ui.select class="max-w-sm" aria-label="{{ __('Filter by status') }}">
<x-slot:icon>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
</svg>
</x-slot:icon>
<option>{{ __('All statuses') }}</option>
<option>{{ __('Active') }}</option>
<option>{{ __('Archived') }}</option>
</x-ui.select>
With Label
<x-ui.field class="w-full max-w-sm">
<x-ui.label for="select-country" :required="true">{{ __('Country') }}</x-ui.label>
<x-ui.select id="select-country" name="country">
<option>{{ __('United States') }}</option>
<option>{{ __('Netherlands') }}</option>
<option>{{ __('Japan') }}</option>
</x-ui.select>
</x-ui.field>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| size | sm | default | lg | md | Control height and text size. |
| invalid | bool | null | false | Overrides validation-derived invalid state. |
| disabled | bool | false | Declared by @props in the shipped Blade source. |
| icon | mixed | null | null | Declared by @props in the shipped Blade source. |
Slots
default— Primary Blade slot rendered by the component.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Uses native browser selection, keyboard, touch, autofill, and form-submission semantics.
- Self-wires validation ARIA when nested in x-ui.field with a matching name.
- Wraps a native select and keeps browser keyboard/form behavior intact.
- Declares registry capability flags: a11y, responsive, rtl, darkMode, localized.
Guidance
Choose from a bounded option set in limited space.
Use when
- Use when users choose among a bounded set and space efficiency matters more than always-visible comparison.
- Selecting one value from a compact list where native platform behavior is desirable.
- A server-submitted form needs resilient no-JavaScript behavior.
Avoid when
- Do not use for tiny lists or when users benefit from seeing all options at once.
- Use to save space with longer lists; do not hide tiny sets that deserve direct comparison.
- Options need search, rich metadata, or custom multi-select behavior; use select-advanced or combobox.
- There are only two to five choices that should remain visible; use radio or choicebox.
Use instead
- Radio or checkbox for visible comparison
Anti-patterns
- Hiding tiny or high-frequency option sets
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- native
- Focus
native
- Associate the select with a visible label.
- Use a disabled empty option for a non-value placeholder.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Livewire can update this component through forwarded wire:* attributes.
Validation
Validation support: laravel-error-bag. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:select
wire:model="value"
:aria-invalid="$errors->has('value') ? 'true' : 'false'"
aria-describedby="value-error"
/>
@error('value')
<p id="value-error" role="alert">{{ $message }}</p>
@enderror
<brok:button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving…</span>
</brok:button>
</form>
Source
The exact, editable file ui:add writes
into your app. Previews render this same code; there are no preview-only components.
{{-- Inherited from a wrapping field with a name (see input for the rationale). --}}
@aware([
'name' => null,
'htmlName' => null,
'hint' => null,
'required' => false,
])
@props([
'size' => 'md',
'invalid' => false,
'disabled' => false,
'icon' => null,
])
@php
$hasIcon = filled($icon ?? null);
// @aware leaves $name undefined when there is no wrapping field, so normalise it.
$awareName = $name ?? null;
$awareHtmlName = $htmlName ?? null;
// Effective field name: the @aware value from a wrapping field, else an explicit
// `name` attribute on the select itself. (Selected-option repopulation lives in
// the slotted options, so old() selection is the consumer's call here; we wire
// name/id/aria so labels + validation messaging associate correctly.)
$fieldName = $awareName ?? $attributes->get('name');
$resolvedHtmlName = $awareName
? ($awareHtmlName ?? collect(explode('.', $awareName))->reduce(
fn ($carry, $segment, $index) => $index === 0 ? $segment : $carry.'['.$segment.']',
''
))
: $attributes->get('name');
$bag = ($errors ?? null) instanceof \Illuminate\Support\ViewErrorBag ? $errors : null;
$hasBagError = $bag && filled($fieldName) && $bag->has($fieldName);
$describedBy = collect(preg_split('/\s+/', trim((string) $attributes->get('aria-describedby', ''))) ?: [])
->when(filled($awareName) && filled($hint ?? null), fn ($ids) => $ids->push($fieldName.'-description'))
->when(filled($awareName) && $hasBagError, fn ($ids) => $ids->push($fieldName.'-error'))
->filter()
->unique()
->implode(' ');
$resolvedId = $attributes->get('id', filled($fieldName) ? $fieldName : null);
$isRequired = (bool) ($required ?? false) || $attributes->has('required');
// Leading icon shifts the option text padding so it clears the icon; the
// trailing chevron always reserves end-padding. Logical props keep it RTL-safe.
$sizes = [
'sm' => ($hasIcon ? 'ps-9' : 'ps-control-x-sm').' h-control-h-sm pe-9 text-sm',
'md' => ($hasIcon ? 'ps-9' : 'ps-control-x-md').' h-control-h-md pe-9 text-sm',
'lg' => ($hasIcon ? 'ps-11' : 'ps-control-x-lg').' h-control-h-lg pe-10 text-base',
];
$iconStart = $size === 'lg' ? 'start-4' : 'start-3';
$base = 'w-full min-w-0 appearance-none rounded-md border bg-background text-foreground shadow-sm transition-colors '
.'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background '
.'disabled:cursor-not-allowed disabled:opacity-50';
$isInvalid = $invalid || $hasBagError;
$state = $isInvalid ? 'border-destructive focus-visible:ring-destructive' : 'border-input';
// 'default' is shadcn's canonical size token; alias it to md (additive).
$size = $size === 'default' ? 'md' : $size;
$classes = trim($base.' '.$state.' '.($sizes[$size] ?? $sizes['md']));
// Width utilities (`w-*`, `max-w-*`, `min-w-*`, with any variant prefix) size
// the wrapper — the element that anchors the chevron/icon overlays — so the
// chevron stays inside a narrowed control instead of drifting to the end of
// a full-width wrapper. Everything else lands on the <select> itself.
[$widthClasses, $controlClasses] = collect(preg_split('/\s+/', (string) $attributes->get('class', '')) ?: [])
->filter()
->partition(fn (string $class) => preg_match('/^(?:[a-z0-9-]+:)*(?:max-w|min-w|w)-/', $class) === 1);
$attributes = $attributes->except('class')->merge(['class' => trim($controlClasses->implode(' '))]);
@endphp
{{-- The wrapper anchors the chevron/icon overlays; consumer `class` lands on the
<select> itself (matching input/textarea) so e.g. `class="h-20"` sizes the
actual control, while width utilities size the wrapper (see above). --}}
<div data-slot="select" class="{{ trim('relative inline-flex w-full items-center '.$widthClasses->implode(' ')) }}">
@if ($hasIcon)
{{-- Optional leading icon: a decorative, pointer-transparent overlay. The
slot supplies an <svg>/icon; we only position and tint it. --}}
<span data-slot="select-icon" aria-hidden="true" class="pointer-events-none absolute {{ $iconStart }} flex size-4 items-center justify-center text-muted-foreground [&_svg]:size-4">
{{ $icon }}
</span>
@endif
<select
@if (filled($resolvedHtmlName)) name="{{ $resolvedHtmlName }}" @endif
@if (filled($resolvedId)) id="{{ $resolvedId }}" @endif
@disabled($disabled)
@required($isRequired)
@if ($isRequired) aria-required="true" @endif
@if ($isInvalid) aria-invalid="true" @endif
@if (filled($describedBy)) aria-describedby="{{ $describedBy }}" @endif
{{ $attributes->except(['name', 'id', 'required', 'aria-required', 'aria-describedby'])->merge(['class' => $classes]) }}
>
{{ $slot }}
</select>
<svg
class="pointer-events-none absolute end-3 size-4 text-muted-foreground"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"
>
<path d="m6 9 6 6 6-6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.5.7 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0