Select
A select constrained to its options - single or multiple, optionally searchable and virtualized, built on Reka UI's Combobox primitive.
Usage
items accepts primitive string/number arrays or option objects. A primitive
is both its displayed label and model value; numbers must be finite, and
numeric 1 and string "1" remain distinct. Object options display label
by default. If an object has a value field, that field is its model value;
otherwise the complete object is the model value. Set labelKey or valueKey
to use different fields.
<script setup lang="ts">
const role = ref('admin')
const count = ref(0)
const roles = ['admin', 'editor', 'viewer']
const counts = [0, 1, 2]
</script>
<template>
<SSelect v-model="role" :items="roles" />
<SSelect v-model="count" :items="counts" />
</template>
Use modelValue for parent-controlled selection or defaultValue for an
uncontrolled initial selection. A form reset restores defaultValue (or the
empty selection) and emits an update; a controlled parent decides whether to
accept it. Pass name to submit one field per selected value. Primitive values
are submitted as strings and object values as JSON. An empty selection submits
no fields; disabled controls submit none. Use form to associate the control
with an external form ID.
New selections come from items; for free text with suggestions, use
Autocomplete instead. An async or
parent-controlled selection may temporarily name an identity whose option has
not loaded yet; Select retains that identity and uses its text as a fallback.
<script setup lang="ts">
const fruit = ref('apple')
const fruitItems = [
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
{ label: 'Cherry', value: 'cherry', disabled: true },
]
</script>
<template>
<SSelect v-model="fruit" placeholder="Pick a fruit" :items="fruitItems" />
</template>
Selected options always show a checkmark in the list, via Reka UI's
ComboboxItemIndicator - this is on by default rather than an opt-in
alternative to a highlighted row.
Filtering
Clicking anywhere on the trigger opens it - not just an icon. When searchable
is set, the filter input lives inside the popover, not in the trigger itself.
<SSelect v-model="fruit" searchable placeholder="Pick a fruit" :items="fruitItems" />
Multiple selection
multiple turns modelValue into an array of option identities, preserving
string and numeric values. displayMode controls how selected values render
in the trigger - comma (default) joins labels as text, chip renders
removable pills. Both modes truncate at maxChips (default 3) and show a "+N
more" summary - hovering it reveals the rest in a tooltip, and clicking it
still opens the popover like the rest of the trigger.
<SSelect v-model="fruits" multiple :max-chips="2" :items="fruitItems" />
<SSelect v-model="fruits" multiple display-mode="chip" :max-chips="2" :items="fruitItems" />
Changing selection mode
For an uncontrolled Select, changing multiple converts its local selection
once: single value becomes [value], single undefined becomes [], a
multiple selection becomes its first value, and an empty array becomes
undefined. Switching to single mode drops later active selections. That
conversion emits one update:modelValue event.
defaultValue is captured at mount and is not retargeted by later prop
changes. A native reset projects that captured default into the current mode:
with defaultValue: [0, 1], reset in single mode yields 0; after changing
back to multiple mode, reset yields [0, 1] again.
A controlled parent must change modelValue and multiple in the same render:
an array when multiple is true, and a scalar or undefined when it is false.
undefined is allowed as the empty multiple value. A scalar value in
multiple mode, or any array in single mode, throws a descriptive error. Select
also requires every value in a multiple modelValue or defaultValue array to
be unique. This includes unresolved async identities; numeric 1 and string
'1' are distinct values.
does not convert controlled values or emit an update solely because the mode
changed.
Checkbox selection
There's no separate "checkbox mode" prop - the item slot already gives you
the option to render whatever you want per row, including a purely visual
Checkbox that mirrors the row's selected state:
<SSelect v-model="fruits" multiple :items="fruitItems">
<template #item="{ item }">
<SCheckbox :model-value="fruits.includes(item.value)" class="pointer-events-none" tabindex="-1" />
{{ item.label }}
</template>
</SSelect>
The checkbox is decorative (pointer-events-none, tabindex="-1") - clicking
the row is still what drives selection, same as any other option.
Clear
clearable adds a button that resets the selection - undefined for a single
select, an empty array for multiple. It only renders once there's something
to clear, and clicking it never opens the popover.
<SSelect v-model="fruit" clearable placeholder="Pick a fruit" :items="fruitItems" />
Custom option rendering
The item slot replaces an option's content in the list (and doubles as a
chip's content in displayMode="chip"); the value slot replaces the
trigger's own display for a single select. Together they let a select carry
more than plain text end to end:
<SSelect v-model="status" :items="statusItems">
<template #item="{ item }">
<span class="size-2 rounded-full" :class="item.color" />
{{ item.label }}
</template>
<template #value="{ selected }">
<span v-if="selected" class="inline-flex items-center gap-2">
<span class="size-2 rounded-full" :class="selected.raw?.color" />
{{ selected.label }}
</span>
</template>
</SSelect>
Custom objects
Select infers its model and update event from the option identity field. Set
valueKey to use a specific field; without it, value is used when present,
and the complete object is used when it is absent. labelKey defaults to
label. Single selection emits the selected identity or object, multiple
emits an array, and a dynamic boolean accepts either shape. Readonly arrays
and groups are supported. Every identity must be unique across the whole list,
including disabled options and options in different groups; a duplicate throws
when Select reads the options. Numeric 1 and string '1' are distinct
identities.
Records with both a string label and an items array represent groups; avoid
that reserved combination on individual options.
For async options, declare the item type even when the array starts empty.
Missing selected values remain intact until an option loads. The value slot
receives selected.raw as undefined during that time; use optional chaining
when reading metadata. The item slot receives a complete option, and an
unresolved object selection can still display its configured label field.
The exported types take an option entry type: SelectProps<number> describes
numeric primitive items, while SelectProps<{ value: number, label: string }>
describes option records. Author object items as a readonly Row[], or as
readonly (Row | SelectGroup<Row>)[] when mixing
options and groups; there are no separate SelectItems, SelectOption, or
SelectOptionGroup authoring aliases. A wrapper that needs the exact validated
items type can use SelectProps<Row, 'id'>['items']. Custom keys and multiple
mode can be declared as SelectProps<Row, 'id', true>; SelectEmits uses the
same parameters. Render functions can specialize the component directly with
h(Select<Row, 'id', true>, props). Template consumers normally need no explicit
generic parameters.
Import public helpers from @sewadah/selaras/types. SelectEntryItem,
SelectEntryGroup, and SelectIdentityKeys are removed internal mechanics;
derive slot types through SelectSlots (or AutocompleteSlots). The internal
normalization functions useComboboxSelect, flattenItems, and isOptionGroup
are no longer auto-imported.
items doesn't have to be { label, value } - point labelKey/valueKey at
whatever fields your data already has:
<SSelect
v-model="userId"
:items="users"
label-key="fullName"
value-key="id"
/>
Grouped options
Nest string, number, or object options under { label, items } entries:
const items = [
{ label: 'Fruits', items: ['Apple', 'Banana'] },
{ label: 'Vegetables', items: [{ label: 'Carrot', value: 'carrot' }] },
]
The group header is plain text by default, but the group slot can replace
it entirely - it receives the whole { label, items } entry:
<SSelect :items="items">
<template #group="{ group }">
<span class="flex items-center justify-between">
<span>{{ group.label }}</span>
<span class="text-[var(--selaras-resolved-text-muted)]">{{ group.items.length }}</span>
</span>
</template>
</SSelect>
Virtualization
For large option lists, virtualize renders only the visible rows (backed by
@tanstack/vue-virtual via Reka UI's ComboboxVirtualizer). Pass true for
defaults, or an object to tune estimateSize/overscan.
<SSelect v-model="value" searchable virtualize :items="fiveThousandItems" />
One caveat: virtualizing a grouped items list flattens the groups (a Reka UI
limitation) - group headers won't render while virtualized.
This only windows what's already in items - it doesn't fetch more data as
you scroll. There's no built-in lazy-loading of additional pages; fetch or
page into items yourself if the full set can't live in memory.
Search text
The popover's search field is a pure filter, not a display of the current
selection - it always resets to empty when you pick an option or close the
popover. Bind v-model:search-term if you need to read or control the typed
text yourself (e.g. to drive a remote search):
<SSelect v-model="fruit" v-model:search-term="query" searchable :items="fruitItems" />
resetSearchTermOnBlur/resetSearchTermOnSelect (both default true,
forwarded from Reka UI's ComboboxRoot) control whether it resets at all -
set either to false if you're driving the field yourself and don't want it
cleared out from under you.
Sizes
size takes sm / md / lg, matching every other form control in this
library - see the Props table below.
States
loading swaps the trailing chevron for a spinner (the trigger stays
clickable - it doesn't disable the select), disabled prevents opening it
entirely, and invalid switches the ring to the danger role's focus color. All three compose
with everything above them on this page - see Props.
Colors
color sets the focus-ring color - the resting (unfocused) ring stays
--selaras-resolved-border-default regardless, and invalid always wins over a custom color:
<SSelect v-model="primary" color="primary" :items="items" />
<SSelect v-model="success" color="success" :items="items" />
<SSelect v-model="danger" color="danger" :items="items" />
Arrow
arrow shows a small pointer triangle connecting the panel to its trigger.
An object sets its width, height, rounded tip, or corner clearance
(padding):
<SSelect v-model="fruit" arrow :items="items" />
<SSelect v-model="fruit" :arrow="{ width: 16, height: 8, rounded: true, padding: 12 }" :items="items" />
Adaptive presentation
adaptive switches the popover to a centered Modal
below your configured adaptive breakpoint. It is opt-in (defaults false),
chooses the presentation on open, and holds it until close:
Panel positioning and portal
Use positioning for the anchored list's side, alignment, offsets, and collision
behavior. portal defaults to the document body; set it to false to render
inline, or to a CSS selector or HTMLElement to choose a teleport target.
These options affect the anchored list; the adaptive Select modal has its own
presentation. Use ui.content for visual styling.
<SSelect :items="fruitItems" :positioning="{ side: 'top', align: 'end', sideOffset: 8 }" portal="#overlay-root" />
Forms integration
Wrap it in FormField to get id/name/invalid and
aria-describedby wired up automatically - invalid and size both fall
back to FormField's own state when not set directly on the select.
Accessibility
Select renders Reka UI's Combobox primitive, so the accessibility semantics
come from there rather than being reimplemented here: the trigger/search
input exposes role="combobox" with aria-expanded/aria-controls, each
option is role="option", and each group is aria-labelledby its header.
Arrow keys move the highlighted option, Enter selects it,
Escape closes the popover, and typing while closed (or while
searchable) filters the list - all standard behavior for the
ARIA combobox pattern,
not something to configure here.
Custom :ui
To see exactly what you'd be overriding - the current default classes for
every slot and variant - here's Select's own theme file:
Global and scoped overrides use ui.select; Autocomplete intentionally reads
the same key because both components use this recipe.
Custom leading and trailing content
Use #leading and #trailing for text, icons, or buttons inside the field frame.
The slots receive { disabled, size, buttonSize }; buttonSize fits a Button
inside the current field size. Give interactive controls an accessible label,
use type="button", and pass disabled through to match the field state.
Custom content stays visible alongside the built-in clear button, loading indicator, and dropdown control.
The field reserves space for the rendered content, including suffixes whose
width changes. Use ui.leading and ui.trailing to customize its wrappers.
Custom controls render outside the trigger button, so they can receive keyboard
focus without creating nested buttons or opening the options list on click.
With adaptive, these slots customize the original field; the modal retains
its dedicated search and selection controls.
Props
| Prop | Type | Default |
|---|---|---|
id | string | - |
name | string | - |
items | readonly Entry[] | - |
valueKey / labelKey | string | 'value' when present, otherwise the option object / 'label' |
modelValue | identity, identity array, or undefined | - |
multiple | boolean | false |
searchable | boolean | false |
searchTerm | string | - |
resetSearchTermOnBlur | boolean | true |
resetSearchTermOnSelect | boolean | true |
virtualize | boolean | { estimateSize?: number; overscan?: number } | false |
displayMode | 'comma' | 'chip' | 'comma' |
maxChips | number | 3 |
clearable | boolean | false |
loading | boolean | false |
placeholder | string | - |
disabled | boolean | false |
size | 'sm' | 'md' | 'lg' | md |
invalid | boolean | false |
color | 'primary' | 'neutral' | 'secondary' | 'success' | 'danger' | 'info' | 'warning' | primary |
arrow | boolean | RoundedArrowConfig | false |
positioning | OverlayPositioning | anchored list defaults |
portal | boolean | string | HTMLElement | true (document body) |
adaptive | boolean | false |
ui | Partial<Record<SelectSlot, string | object>> | - |
Slots
| Slot | Props | Description |
|---|---|---|
leading / trailing | { disabled, size, buttonSize } | Custom field content alongside built-in controls |
item | { item } | Custom option rendering (also used for chips) |
value | { selected } | Custom trigger display (single-select only) |
group | { group } | Custom group header (replaces the plain label text) |
empty | - | Shown when items is empty |
empty-filter | - | Shown when a search yields no matches |
header / footer | - | Content above/below the option list |
clear-icon | - | Replaces the clear button's icon (default: hugeicons:cancel-01) |
dropdown-icon | - | Replaces the trigger's chevron (default: hugeicons:arrow-down-01) |
loading-icon | - | Replaces the spinner shown while loading (default: hugeicons:loading-02) |
filter-icon | - | Replaces the popover search field's icon (default: hugeicons:search-01) |