Select

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

Example

Auto width
Matches the widest option by default, closer to a native select.
Value: No selection yet
Full width
Opt in with class="w-full" when you want the trigger to fill its container.
Value: strawberry-cheesecake
vue
<script setup lang="ts">
import { ref } from 'vue'
import { Select } from 'frappe-ui'

const autoWidthValue = ref('')
const fullWidthValue = ref('strawberry-cheesecake')

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>
  <div class="w-full max-w-4xl">
    <div
      class="grid divide-y divide-outline-gray-2 md:grid-cols-2 md:divide-x md:divide-y-0"
    >
      <div class="pb-8 md:pr-8">
        <div class="text-sm font-medium text-ink-gray-7">Auto width</div>
        <div class="mt-1 text-p-sm text-ink-gray-5">
          Matches the widest option by default, closer to a native select.
        </div>

        <div class="mt-4">
          <Select
            v-model="autoWidthValue"
            :options="options"
            variant="outline"
          />
        </div>

        <div class="mt-4 text-sm text-ink-gray-5">
          Value:
          <span class="text-ink-gray-8">
            {{ autoWidthValue || 'No selection yet' }}
          </span>
        </div>
      </div>

      <div class="pb-8 md:pl-8">
        <div class="text-sm font-medium text-ink-gray-7">Full width</div>
        <div class="mt-1 text-p-sm text-ink-gray-5">
          Opt in with
          <code class="text-sm text-ink-gray-7">class="w-full"</code>
          when you want the trigger to fill its container.
        </div>

        <div class="mt-4 w-[320px] max-w-full">
          <Select
            v-model="fullWidthValue"
            :options="options"
            variant="outline"
            class="w-full"
          />
        </div>

        <div class="mt-4 text-sm text-ink-gray-5">
          Value:
          <span class="text-ink-gray-8">{{ fullWidthValue }}</span>
        </div>
      </div>
    </div>
  </div>
</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.

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>

States

Disabled label option
Common sorting pattern where a disabled first option keeps an empty string value without breaking the select.
Value: Empty string
vue
<script setup lang="ts">
import { ref } from 'vue'
import { Select } from 'frappe-ui'

const sortOrder = ref('')

const sortOptions = [
  {
    label: 'Sort by',
    value: '',
    disabled: true,
  },
  {
    label: 'Newest first',
    value: 'newest',
  },
  {
    label: 'Oldest first',
    value: 'oldest',
  },
  {
    label: 'Most popular',
    value: 'popular',
  },
]
</script>

<template>
  <div class="w-full max-w-md py-4">
    <div class="text-sm font-medium text-ink-gray-7">Disabled label option</div>
    <div class="mt-1 text-p-sm text-ink-gray-5">
      Common sorting pattern where a disabled first option keeps an empty string
      value without breaking the select.
    </div>

    <div class="mt-4">
      <Select v-model="sortOrder" :options="sortOptions" variant="outline" />
    </div>

    <div class="mt-4 text-sm text-ink-gray-5">
      Value:
      <span class="text-ink-gray-8">
        {{ sortOrder === '' ? 'Empty string' : sortOrder }}
      </span>
    </div>
  </div>
</template>

Trigger Slots

Prefix and suffix
Use the default trigger shell when you only need light customization.
Custom trigger
Replace the trigger content entirely when you need richer layout.
vue
<script setup lang="ts">
import { ref } from 'vue'
import { Select } from 'frappe-ui'

const assignee = ref('faris')
const reviewer = ref('mariam')

const options = [
  {
    label: 'Faris',
    value: 'faris',
  },
  {
    label: 'Aakvatech',
    value: 'aakvatech',
  },
  {
    label: 'Hadi',
    value: 'hadi',
  },
  {
    label: 'Mariam',
    value: 'mariam',
  },
  {
    label: 'Suhail',
    value: 'suhail',
  },
]
</script>

<template>
  <div
    class="grid w-full max-w-4xl divide-y divide-outline-gray-2 md:grid-cols-2 md:divide-x md:divide-y-0"
  >
    <div class="py-4 md:pr-8">
      <div class="text-sm font-medium text-ink-gray-7">Prefix and suffix</div>
      <div class="mt-1 text-p-sm text-ink-gray-5">
        Use the default trigger shell when you only need light customization.
      </div>

      <div class="mt-4">
        <Select v-model="assignee" :options="options" variant="outline">
          <template #prefix>
            <span class="lucide-user size-4 text-ink-gray-6" />
          </template>

          <template #suffix>
            <div class="ml-auto flex items-center gap-2 text-ink-gray-5">
              <span
                class="rounded bg-surface-gray-2 px-1.5 py-0.5 text-sm text-ink-gray-6"
              >
                5
              </span>
              <span class="lucide-chevron-down size-4 text-ink-gray-6" />
            </div>
          </template>
        </Select>
      </div>
    </div>

    <div class="py-4 md:pl-8">
      <div class="text-sm font-medium text-ink-gray-7">Custom trigger</div>
      <div class="mt-1 text-p-sm text-ink-gray-5">
        Replace the trigger content entirely when you need richer layout.
      </div>

      <div class="mt-4 w-[320px] max-w-full">
        <Select
          v-model="reviewer"
          :options="options"
          variant="outline"
          class="w-full"
        >
          <template #trigger="{ displayValue, open }">
            <div class="flex w-full items-center gap-3">
              <div
                class="flex size-7 shrink-0 items-center justify-center rounded-full bg-surface-gray-2"
              >
                <span class="lucide-users size-4 text-ink-gray-6" />
              </div>

              <div class="min-w-0 flex-1 py-1.5">
                <div class="truncate">
                  {{ displayValue || 'Choose reviewer' }}
                </div>
                <div class="truncate text-p-sm text-ink-gray-5">
                  Design review queue
                </div>
              </div>

              <span
                :class="[
                  'lucide-chevron-down size-4 shrink-0 text-ink-gray-4 transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]',
                  open ? 'rotate-180' : '',
                ]"
              />
            </div>
          </template>
        </Select>
      </div>
    </div>
  </div>
</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. The slot receives selectedOption and clearSelection.

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, clearSelection }">
        <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="clearSelection"
          >
            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.

We'll pick a default for you when you don't choose.

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

const value = ref('')
const required = ref(true)
const showError = ref(false)

const error = computed(() =>
  showError.value ? 'Please choose a fruit.' : '',
)

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

<template>
  <div class="flex gap-8 items-start">
    <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="required"
      placeholder="Pick one"
      class="w-72"
    />
    <div
      class="flex flex-col gap-2 items-start border-l border-outline-gray-2 pl-6"
    >
      <Checkbox v-model="required" label="required" />
      <Checkbox v-model="showError" label="show error" />
    </div>
  </div>
</template>

Notes

  • Prefer #item-prefix, #item-label, and #item-suffix when you want to customize the standard option row.
  • Use v-model:open when you need to control the menu state.
  • 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. Empty and nullish options are omitted.

API Reference

Show types
typescript
import type { Component } from 'vue'
import type { InputLabelingProps } from '../../composables/useInputLabeling'

export type SelectOptionValue = string | number | bigint | Record<string, any>

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
}

export interface SelectTriggerSlotProps {
  /** Whether the select menu is currently open. */
  open: boolean

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

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

  /** Plain-text label shown in the trigger. */
  displayValue: string

  /** Clears the current selection (sets the model to `undefined`). */
  clearSelection: () => void
}

/**
 * Shared shape for `#trigger`, `#prefix`, and `#suffix`. `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 trio.
 */
export type SelectSlotProps = SelectTriggerSlotProps
export type SelectPrefixSlotProps = SelectSlotProps
export type SelectSuffixSlotProps = SelectSlotProps

export interface SelectFooterSlotProps {
  /** Currently selected option, if any. */
  selectedOption: SelectNormalizedOption | null

  /** Clears the current selection (sets the model to `undefined`). */
  clearSelection: () => void
}

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

  /**
   * @deprecated Use `item`. Retained as a silent alias through v1.x for
   * back-compat with the pre-v1 `{ option }` slot-prop shape.
   */
  option: SelectNormalizedOption
}

export interface SelectSlots {
  /** 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

  /**
   * Shared renderer for option labels.
   * @deprecated use `#item-label` for per-row label customization. `#option` remains as a back-compat alias through v1.x.
   */
  option?: (props: SelectItemSlotProps) => any

  /** 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

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

  /** Content rendered below the option list. Stays pinned below the
   * scrollable options. */
  footer?: (props: SelectFooterSlotProps) => any

  [slotName: string]: ((props: any) => any) | undefined
}

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

  /** Fired when the open state changes. */
  'update:open': [value: boolean]
}

export interface SelectExposed {}
size
= "sm"
"md" | "sm" | "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.

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.

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 and forwards `required` / `aria-required` to the underlying control.

id
string

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

trigger
SelectTriggerSlotProps

Fully custom trigger renderer.

label
{ required: boolean; }

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

description

Overrides the rendered description content.

prefix
SelectTriggerSlotProps

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

suffix
SelectTriggerSlotProps

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

option

Deprecated — use `#item-label` for per-row label customization. `#option` remains as a back-compat alias through v1.x.

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.

empty

Fallback content rendered when no options are available.

footer
SelectFooterSlotProps

Content rendered below the option list. Stays pinned below the scrollable options.

update:modelValue
[value: SelectOptionValue | undefined]

Fired when the model value changes.

update:open
[value: boolean]

Fired when the open state changes.