Skip to content
Brok UI

Loading…

No results

Select

Open source Core

Pick a value from a list — the native select, a custom listbox, a filterable combobox or a scrolling wheel.

Version
v1.5.7
Stability
stable
License
MIT
Related
Advanced Select
Combobox
Radio
Field

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

Size
Disabled
previews.components.select.default.blade.php Blade
<x-ui.select aria-label="{{ __('Country') }}" class="max-w-sm">
    <option>{{ __('United States') }}</option>
    <option>{{ __('Netherlands') }}</option>
    <option>{{ __('Japan') }}</option>
</x-ui.select>
Sm Current
Md Current
Lg Current

Installation

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

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

select.md
# 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

groups.blade.php Blade
<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>
in-form.blade.php Blade
<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.blade.php Blade
<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>
sizes.blade.php Blade
<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>
states.blade.php Blade
<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.blade.php Blade
<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.blade.php Blade
<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.blade.php Blade
<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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

select select-icon

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

Bounded compact selection

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
root leading icon native select chevron
Theming hooks
select root select icon invalid ring

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Safe

Livewire can update this component through forwarded wire:* attributes.

Validation

Validation support: laravel-error-bag. Keep the error message connected with aria-describedby.

livewire-form.blade.php Blade
<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.

resources/views/components/ui/select.blade.php Blade
{{-- 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