Select
Lets users select one option from a list. Ideal for forms, settings, or any interface where a single choice is required.
Playground
<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.
<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.
<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.
<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>Footer
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 }.
<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.
<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.
<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:openwhen a parent owns the menu state; usesetOpenfrom the slot props when the code lives inside#triggeror#footer. - By default,
Selectsizes itself to fit its option content. Setclass="w-full"when you want a full-width trigger. Selectaccepts flat options only — no groups. Empty and nullish options are omitted. Option values arestring | 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, oroffsetswitches it to standard popper placement;portalTochanges the teleport target either way. - For a searchable single-choice picker, use
Combobox; for several values, useMultiSelect.
API Reference
Show types
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 of the select input.
Visual style of the select input.
Placeholder text displayed when no option is selected.
If true, disables the select input.
The currently selected value.
Controls the visibility of the select menu.
Options to display in the dropdown.
Fallback empty-state copy rendered when no options are available.
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.
Preferred popover alignment. Defaults to `'start'`. See `side`.
Gap in px between trigger and content. Defaults to `4`. See `side`.
Teleport target for the popover content. Unset, an embedding host's target is used, else `body`.
Label rendered above (or beside, for binary controls) the input.
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 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).
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.
HTML id of the underlying control. Auto-generated via `useId()` if omitted.
| Slot | Payload |
|---|---|
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. |
Fully custom trigger renderer.
Overrides the rendered label content. Receives `{ required }`.
Overrides the rendered description content.
Content rendered before the trigger value. Receives the same shape as `#trigger` and `#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.
Replaces the entire option row, including the row shell. A per-option `slot` (`#item-<name>`) takes precedence over this slot.
Fallback content rendered when no options are available.
Content rendered below the option list. Stays pinned below the scrollable options. Receives the same shape as `#trigger`.
Content rendered before the standard option label.
Content rendered for the standard option label area.
Content rendered after the standard option label.
| Event | Payload |
|---|---|
update:open | [value: boolean] Fired when the open state changes. |
update:modelValue | [value: SelectOptionValue | undefined] Fired when the model value changes. |
Fired when the open state changes.
Fired when the model value changes.