Skip to content

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.

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

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

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

vue-html
<SSelect v-model="fruits" multiple :max-chips="2" :items="fruitItems" />
vue-html
<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:

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

vue-html
<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:

vue-html
<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:

vue-html
<SSelect
  v-model="userId"
  :items="users"
  label-key="fullName"
  value-key="id"
/>

Grouped options

Nest string, number, or object options under { label, items } entries:

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

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

vue-html
<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):

vue-html
<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:

vue-html
<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):

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

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

Theme source "select" not found

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.

Pay in

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

PropTypeDefault
idstring-
namestring-
itemsreadonly Entry[]-
valueKey / labelKeystring'value' when present, otherwise the option object / 'label'
modelValueidentity, identity array, or undefined-
multiplebooleanfalse
searchablebooleanfalse
searchTermstring-
resetSearchTermOnBlurbooleantrue
resetSearchTermOnSelectbooleantrue
virtualizeboolean | { estimateSize?: number; overscan?: number }false
displayMode'comma' | 'chip''comma'
maxChipsnumber3
clearablebooleanfalse
loadingbooleanfalse
placeholderstring-
disabledbooleanfalse
size'sm' | 'md' | 'lg'md
invalidbooleanfalse
color'primary' | 'neutral' | 'secondary' | 'success' | 'danger' | 'info' | 'warning'primary
arrowboolean | RoundedArrowConfigfalse
positioningOverlayPositioninganchored list defaults
portalboolean | string | HTMLElementtrue (document body)
adaptivebooleanfalse
uiPartial<Record<SelectSlot, string | object>>-

Slots

SlotPropsDescription
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)
Edit this page