Select ​

A button that opens a list of options and picks one. For a searchable list, use Combobox, and to pick several values, use MultiSelect.

label
size
variant
<Select
  label="Status"
  placeholder="Select status"
  :options="options"
  v-model="value"
/>

Examples ​

Dessert menu ​

#item-prefix and #item-label fill the standard row with a photo and a second line of text. #prefix shows the selected option's photo on the trigger.

vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Avatar, Select } from 'frappe-ui'

const value = ref('matcha-tiramisu')

const options = [
  {
    label: 'Matcha Tiramisu',
    value: 'matcha-tiramisu',
    image:
      'https://images.unsplash.com/photo-1563805042-7684c019e1cb?w=150&h=150&fit=crop',
    description: 'Kyoto cream · Soft sponge · Ceremonial matcha',
    price: '$14',
  },
  {
    label: 'Strawberry Cheesecake',
    value: 'strawberry-cheesecake',
    image:
      'https://images.unsplash.com/photo-1533134486753-c833f0ed4866?w=150&h=150&fit=crop',
    description: 'Fresh berries · Baked filling · Biscuit crust',
    price: '$16',
  },
  {
    label: 'Chocolate Lava Cake',
    value: 'chocolate-lava-cake',
    image:
      'https://images.unsplash.com/photo-1624353365286-3f8d62daad51?w=150&h=150&fit=crop',
    description: 'Warm center · Dark chocolate · Sea salt',
    price: '$15',
  },
  {
    label: 'Mango Sticky Rice',
    value: 'mango-sticky-rice',
    image:
      'https://images.unsplash.com/photo-1604085792782-8d92f276d7d8?w=150&h=150&fit=crop',
    description: 'Coconut cream · Sweet mango · Toasted sesame',
    price: '$13',
    disabled: true,
  },
]

const activeOption = computed(() => {
  return options.find((option) => option.value === value.value) ?? null
})
</script>

<template>
  <div>
    <Select v-model="value" :options="options" variant="outline" class="w-full">
      <template #prefix>
        <Avatar
          v-if="activeOption"
          size="sm"
          :image="activeOption.image"
          :label="activeOption.label"
        />
      </template>

      <template #item-prefix="{ item }">
        <Avatar size="sm" :image="item.image" :label="item.label" />
      </template>

      <template #item-label="{ item }">
        <div class="min-w-0">
          <div class="truncate">{{ item.label }}</div>
          <div
            class="truncate text-p-sm text-ink-gray-5"
            :class="item.disabled ? 'opacity-65' : ''"
          >
            {{ item.description }}
          </div>
        </div>
      </template>
    </Select>
  </div>
</template>

Inline in a sentence ​

#trigger replaces the whole trigger, so the choice reads as part of the sentence.

Notify me in this project.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { Select } from 'frappe-ui'

const frequency = ref('mentions')

const options = [
  { label: 'for every message', value: 'all' },
  { label: 'for @mentions only', value: 'mentions' },
  { label: 'never', value: 'never' },
]
</script>

<template>
  <!--
    An inline trigger reads as part of the sentence rather than as a form
    control. `#prefix` / `#suffix` can't get here — they add to the default
    trigger shell, while `#trigger` replaces it. `variant="ghost"` and
    `size="sm"` keep the shell from drawing a border or a tall row.
  -->
  <p class="max-w-md text-base text-ink-gray-7">
    Notify me
    <Select
      v-model="frequency"
      :options="options"
      variant="ghost"
      size="sm"
      class="!px-0"
    >
      <template #trigger="{ selectedOption, open }">
        <span
          :class="[
            'rounded-4 px-1 -mx-1 font-medium text-ink-gray-8 decoration-outline-gray-3 underline-offset-4 transition-colors duration-150',
            open ? 'bg-surface-gray-3' : 'underline hover:bg-surface-gray-3',
          ]"
        >
          {{ selectedOption?.label }}
        </span>
      </template>
    </Select>
    in this project.
  </p>
</template>

Timezone picker ​

The #footer slot sits below the options and stays in place while the list scrolls.

Selected: None
vue
<script setup lang="ts">
import { ref } from 'vue'
import { Select } from 'frappe-ui'

const value = ref('')

// A long list so the viewport scrolls — the footer should stay pinned to the
// bottom of the popover instead of scrolling away with the options.
const timezones = [
  'UTC-12:00',
  'UTC-11:00',
  'UTC-10:00',
  'UTC-09:00',
  'UTC-08:00',
  'UTC-07:00',
  'UTC-06:00',
  'UTC-05:00',
  'UTC-04:00',
  'UTC-03:00',
  'UTC-02:00',
  'UTC-01:00',
  'UTC+00:00',
  'UTC+01:00',
  'UTC+02:00',
  'UTC+03:00',
  'UTC+04:00',
  'UTC+05:00',
  'UTC+05:30',
  'UTC+06:00',
  'UTC+07:00',
  'UTC+08:00',
  'UTC+09:00',
  'UTC+10:00',
  'UTC+11:00',
  'UTC+12:00',
]
</script>

<template>
  <div class="grid gap-3">
    <Select v-model="value" :options="timezones" placeholder="Pick a timezone">
      <template #footer="{ selectedOption, clear, setOpen }">
        <div
          class="flex items-center justify-between border-t border-outline-gray-1 px-3 py-2 text-sm text-ink-gray-5"
        >
          <span>{{ timezones.length }} timezones</span>
          <button
            v-if="selectedOption"
            class="text-ink-gray-7 hover:text-ink-gray-8"
            @click="
              () => {
                clear()
                setOpen(false)
              }
            "
          >
            Clear
          </button>
          <span v-else class="text-ink-gray-7">Footer stays fixed</span>
        </div>
      </template>
    </Select>

    <div class="text-sm text-ink-gray-5">
      Selected: <code class="text-ink-gray-7">{{ value || 'None' }}</code>
    </div>
  </div>
</template>

Behavior ​

Options ​

Select takes a flat list of options. It has no groups. Empty and nullish options are left out. Option values are string | number. An option with disabled: true shows in the list but cannot be picked.

Empty value ​

When nothing is selected, the value is null, the same as in Combobox. An empty string is a real value, so a "None" option with value: '' round-trips.

For a "Sort by" menu, add a first option with value: '' and disabled: true. It shows as the label while nothing else is picked, and people cannot select it.

Width ​

Select sizes itself to fit its options. The trigger fits the selected value, and the menu grows to fit longer options. Add class="w-full" for a full-width trigger.

Item slots ​

#item-prefix, #item-label and #item-suffix change parts of the standard row. #item replaces the whole row, including its outer element.

Trigger slots ​

#trigger replaces the trigger content. For smaller changes, #prefix and #suffix sit inside the default trigger. #suffix replaces the chevron.

#trigger, #prefix, #suffix and #footer all receive { open, disabled, selectedOption, clear, setOpen, close }. close() is the same as setOpen(false).

Open state ​

Use v-model:open when a parent component controls the menu. Inside #trigger or #footer, use the setOpen slot prop.

By default the menu opens over the trigger, lined up with the selected option. Pass side, align or offset to place it next to the trigger instead. portalTo changes where the menu is teleported in both modes.

Label, description and error ​

label renders above the trigger and description below it. error renders below the trigger and hides description. It takes a string, an array of strings (one line each), or an Error, the same values as ErrorMessage. An empty string or an empty array means no error. required adds a red asterisk to the label.

The #label slot replaces the label text and the required marker, and receives { required }. A #description slot is not hidden by error. It renders above the error.

Accessibility ​

While error is set, the trigger gets aria-invalid and an aria-errormessage that points to the error text.

API Reference ​

Show types
typescript
import type { Component } from 'vue'
import type { InputSize, InputVariant } from '../../composables/inputTypes'
import type { InputLabelingProps } from '../../composables/useInputLabeling'
import type { PopoverAlign, PopoverSide } from '../shared/selection/types'
import type { PortalTarget } from '../../composables/usePortalTarget'

export type { PopoverAlign, PopoverSide }

/**
 * Option values are `string | number` across the whole selection family.
 * `''` is a legitimate value (a "none" / reset row) and is round-tripped
 * through `useEmptyValueMapping`.
 */
export type SelectOptionValue = string | number

export type SelectOption =
  | string
  | {
      label: string
      value: SelectOptionValue
      disabled?: boolean
      icon?: string | Component
      description?: string
      slot?: string
      [key: string]: any
    }

export type SelectNormalizedOption = Exclude<SelectOption, string>

export interface SelectProps extends InputLabelingProps {
  /** Size of the select input. */
  size?: InputSize

  /** Visual style of the select input. */
  variant?: InputVariant

  /** Placeholder text displayed when no option is selected. */
  placeholder?: string

  /** If true, disables the select input. */
  disabled?: boolean

  /** The currently selected value. `null` when nothing is selected. */
  modelValue?: SelectOptionValue | null

  /** Controls the visibility of the select menu. */
  open?: boolean

  /** Options to display in the dropdown. */
  options?: SelectOption[]

  /** Fallback empty-state copy rendered when no options are available. */
  emptyText?: string

  /**
   * Preferred popover side. Defaults to `'bottom'`.
   *
   * Setting `side`, `align`, or `offset` switches the menu from its default
   * item-aligned placement (anchored over the trigger, macOS-style) to
   * standard popper placement below/beside the trigger.
   */
  side?: PopoverSide

  /** Preferred popover alignment. Defaults to `'start'`. See `side`. */
  align?: PopoverAlign

  /** Gap in px between trigger and content. Defaults to `4`. See `side`. */
  offset?: number

  /** Teleport target for the popover content. Unset, an embedding host's target is used, else `body`. */
  portalTo?: PortalTarget
}

/**
 * Shared shape for `#trigger`, `#prefix`, `#suffix`, and `#footer`.
 * `selectedOption` is always `null` in `#prefix` because the prefix only
 * renders before a selection — the field is still exposed for slot-prop
 * symmetry across the group.
 *
 * `clear` and `setOpen` are the inside-out helpers: code running inside a
 * slot has no reference to the parent's model or open state.
 */
export interface SelectSlotProps {
  /** Whether the select menu is currently open. */
  open: boolean

  /** Whether the trigger is disabled. */
  disabled: boolean

  /** Currently selected option, if any. */
  selectedOption: SelectNormalizedOption | null

  /** Clears the current selection (sets the model to `null`). */
  clear: () => void

  /** Sets the menu open state. */
  setOpen: (value: boolean) => void
  /** Closes the menu. Equivalent to `setOpen(false)`. */
  close: () => void
}

export type SelectTriggerSlotProps = SelectSlotProps
export type SelectPrefixSlotProps = SelectSlotProps
export type SelectSuffixSlotProps = SelectSlotProps

export interface SelectItemSlotProps {
  /** Item currently being rendered. */
  item: SelectNormalizedOption

  /** Whether the item is the current `modelValue`. */
  selected: boolean
}

/**
 * Fixed slot names. Kept separate from `SelectSlots` so the dynamic
 * `` `item-${string}` `` index signature can be intersected in without
 * constraining names that don't match the pattern.
 */
interface SelectFixedSlots {
  /** Fully custom trigger renderer. */
  trigger?: (props: SelectTriggerSlotProps) => any

  /** Overrides the rendered label content. Receives `{ required }`. */
  label?: (props: { required: boolean }) => any

  /** Overrides the rendered description content. */
  description?: () => any

  /** Content rendered before the trigger value. Receives the same shape
   * as `#trigger` and `#suffix` (`SelectSlotProps`). */
  prefix?: (props: SelectPrefixSlotProps) => any

  /**
   * Content rendered after the trigger value. Providing this slot
   * **replaces the default chevron** — render your own fallback when
   * your slot content is conditional.
   */
  suffix?: (props: SelectSuffixSlotProps) => any

  /**
   * Replaces the entire option row, including the row shell. A per-option
   * `slot` (`#item-<name>`) takes precedence over this slot.
   */
  item?: (props: SelectItemSlotProps) => any

  /** Fallback content rendered when no options are available. */
  empty?: () => any

  /** Content rendered below the option list. Stays pinned below the
   * scrollable options. Receives the same shape as `#trigger`. */
  footer?: (props: SelectSlotProps) => any
}

/**
 * Item slot names: the three fixed regions of the row shell, plus any
 * `#item-<name>` dispatched from an option's `slot` field.
 *
 * The index signature is deliberately narrowed to `` `item-${string}` `` —
 * `Select` resolves `option.slot` to `` `item-${option.slot}` ``, so this is
 * exactly the runtime behavior, and every fixed slot name stays typechecked
 * instead of every typo compiling clean.
 */
interface SelectItemSlotsByName {
  /** Content rendered before the standard option label. */
  'item-prefix'?: (props: SelectItemSlotProps) => any

  /** Content rendered for the standard option label area. */
  'item-label'?: (props: SelectItemSlotProps) => any

  /** Content rendered after the standard option label. */
  'item-suffix'?: (props: SelectItemSlotProps) => any

  /** Per-option dynamic slot, selected by the option's `slot` field. */
  [slotName: `item-${string}`]:
    | ((props: SelectItemSlotProps) => any)
    | undefined
}

export interface SelectSlots extends SelectFixedSlots, SelectItemSlotsByName {}

/**
 * The events this interface names for a wrapper to reuse. `update:modelValue`
 * is not here: `defineModel` declares it, and the generated payload carries
 * `undefined` because the model prop is optional. Declaring it again published
 * one event twice with two payload types, so a wrapper that re-bound
 * `@update:model-value` failed to compile. `ComboboxEmits` and
 * `MultiSelectEmits` dropped their model events for the same reason. The
 * runtime event is unchanged: the component emits a value or `null`.
 */
export interface SelectEmits {
  /** Fired when the open state changes. */
  'update:open': [value: boolean]
}
size
= "sm"
InputSize

Size of the select input.

variant
= "subtle"
InputVariant

Visual style of the select input.

placeholder
= "Select option"
string

Placeholder text displayed when no option is selected.

disabled
boolean

If true, disables the select input.

modelValue
SelectOptionValue | null

The currently selected value. `null` when nothing is selected.

open
= false
boolean

Controls the visibility of the select menu.

options
= []
SelectOption[]

Options to display in the dropdown.

emptyText
= "No options"
string

Fallback empty-state copy rendered when no options are available.

side
PopoverSide

Preferred popover side. Defaults to `'bottom'`. Setting `side`, `align`, or `offset` switches the menu from its default item-aligned placement (anchored over the trigger, macOS-style) to standard popper placement below/beside the trigger.

align
PopoverAlign

Preferred popover alignment. Defaults to `'start'`. See `side`.

offset
number

Gap in px between trigger and content. Defaults to `4`. See `side`.

portalTo
PortalTarget

Teleport target for the popover content. Unset, an embedding host's target is used, else `body`.

label
string

Label rendered above (or beside, for binary controls) the input.

description
string

Helper text rendered below the input. Hidden when `error` is set. A `#description` slot is not: it renders beside the error, and is referenced alongside it.

error
ErrorMessageValue

Error message rendered below the input. When set, the control receives `aria-invalid="true"` and `data-state="invalid"`. Takes a string, an array of strings, or an `Error` whose `messages` are rendered as stacked lines (with `Error.message` as the fallback). This is the same value `ErrorMessage.message` takes. An empty array and an empty string both mean no error.

required
boolean

Marks the field as required. Renders an asterisk next to the label, with `sr-only` text that announces it, and forwards `required` / `aria-required` to the underlying control where the control's role allows it. `data-required` is set either way.

id
string

HTML id of the underlying control. Auto-generated via `useId()` if omitted.

trigger
SelectSlotProps

Fully custom trigger renderer.

label
{ required: boolean; }

Overrides the rendered label content. Receives `{ required }`.

description
—

Overrides the rendered description content.

prefix
SelectSlotProps

Content rendered before the trigger value. Receives the same shape as `#trigger` and `#suffix` (`SelectSlotProps`).

suffix
SelectSlotProps

Content rendered after the trigger value. Providing this slot **replaces the default chevron** — render your own fallback when your slot content is conditional.

item
SelectItemSlotProps

Replaces the entire option row, including the row shell. A per-option `slot` (`#item-<name>`) takes precedence over this slot.

empty
—

Fallback content rendered when no options are available.

footer
SelectSlotProps

Content rendered below the option list. Stays pinned below the scrollable options. Receives the same shape as `#trigger`.

item-prefix
SelectItemSlotProps

Content rendered before the standard option label.

item-label
SelectItemSlotProps

Content rendered for the standard option label area.

item-suffix
SelectItemSlotProps

Content rendered after the standard option label.

update:open
[value: boolean]

Fired when the open state changes.

update:modelValue
[value: SelectOptionValue | null | undefined]

Fired when the model value changes.

clear
() => void

Clears the current selection. It leaves the search query alone.

focus
(options?: FocusOptions) => void

Moves focus to the component's interactive element.