Frappe UIFrappe UI

Select

Lets users select one option from a list. Ideal for forms, settings, or any interface where a single choice is required.

Playground

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

Example

The trigger hugs the selected value and the menu expands outward to fit longer options. Options with disabled: true render but can't be picked.

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

const value = ref('')

const options = [
  { label: 'Matcha Tiramisu', value: 'matcha-tiramisu' },
  { label: 'Strawberry Cheesecake', value: 'strawberry-cheesecake' },
  { label: 'Chocolate Lava Cake', value: 'chocolate-lava-cake' },
  { label: 'Mango Sticky Rice', value: 'mango-sticky-rice', disabled: true },
  { label: 'Pistachio Baklava', value: 'pistachio-baklava' },
  { label: 'Ube Ice Cream', value: 'ube-ice-cream' },
  { label: 'Salted Caramel Tart', value: 'salted-caramel-tart' },
]
</script>

<template>
  <Select
    v-model="value"
    :options="options"
    variant="outline"
    placeholder="Pick a dessert"
  />
</template>

Custom Option Layout

Use #item-prefix and #item-label to tailor the standard row — for example, an avatar plus a two-line label with a secondary description. #prefix on the trigger reuses the selected option's accessory. Use #item when you want to replace the entire row, shell included.

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>

Custom Trigger

Use #trigger to replace the trigger content entirely. The slot receives { open, disabled, selectedOption, clear, setOpen }. For lighter changes, #prefix and #suffix sit inside the default trigger shell — #suffix replaces the chevron.

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>

The #footer slot renders below the option list and stays pinned to the bottom of the popover — it does not scroll with the options. It receives the same shape as #trigger, #prefix, and #suffix: { open, disabled, selectedOption, clear, setOpen }.

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>

Label, Description, Error

Select supports label, description, error, and required directly — no FormControl wrapper needed. The error suppresses the description and wires aria-invalid + aria-errormessage onto the trigger.

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

const value = ref('')

// Starts empty so the error shows on load. Picking an option clears it and
// the description takes the slot back — the error always wins while it's set.
const error = computed(() => (value.value ? '' : 'Please choose a fruit.'))

const options = [
  { label: 'Apple', value: 'apple' },
  { label: 'Banana', value: 'banana' },
  { label: 'Cherry', value: 'cherry' },
]
</script>

<template>
  <Select
    v-model="value"
    :options="options"
    label="Favourite fruit"
    description="We'll pick a default for you when you don't choose."
    :error="error"
    required
    placeholder="Pick one"
    class="w-72"
  />
</template>

Template Ref

A template ref exposes { clear, focus } — the same shape as Combobox and MultiSelect. clear() empties the selection; focus() moves focus to the trigger.

vue
<script setup lang="ts">
import { useTemplateRef } from 'vue'

const picker = useTemplateRef('picker')

function reset() {
  picker.value?.clear()
  picker.value?.focus()
}
</script>

<template>
  <Select ref="picker" v-model="value" :options="options" />
</template>

Notes

  • Use v-model:open when a parent owns the menu state; use setOpen from the slot props when the code lives inside #trigger or #footer.
  • By default, Select sizes itself to fit its option content. Set class="w-full" when you want a full-width trigger.
  • Select accepts flat options only — no groups. Empty and nullish options are omitted. Option values are string | number.
  • For the common "Sort by" pattern, add a first option with an empty-string value and disabled: true. It shows as the resting label without becoming selectable.
  • The menu is placed item-aligned (anchored over the trigger) by default. Passing side, align, or offset switches it to standard popper placement; portalTo changes the teleport target either way.
  • For a searchable single-choice picker, use Combobox; for several values, use MultiSelect.

API Reference

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

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?: 'sm' | 'md' | 'lg' | 'xl'

  /** Visual style of the select input. */
  variant?: 'subtle' | 'outline' | 'ghost'

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

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

  /** The currently selected value. */
  modelValue?: SelectOptionValue

  /** 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?: string | HTMLElement
}

/**
 * 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 `undefined`). */
  clear: () => void

  /** Sets the menu open state. */
  setOpen: (value: boolean) => 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 {}

export interface SelectEmits {
  /** Fired when the selected value changes. */
  'update:modelValue': [value: SelectOptionValue | undefined]

  /** Fired when the open state changes. */
  'update:open': [value: boolean]
}
size
= "sm"
"sm" | "md" | "lg" | "xl"

Size of the select input.

variant
= "subtle"
"subtle" | "outline" | "ghost"

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

The currently selected value.

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
string | HTMLElement

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
string | FrappeUIError

Error message rendered below the input. When set, the control receives `aria-invalid="true"` and `data-state="invalid"`. May be either a string or an `Error` object whose `messages?: string[]` is rendered as stacked lines (with `Error.message` as the fallback).

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 | undefined]

Fired when the model value changes.