SelectField
Dropdown select field with static or async options, search support, and grouping
SelectField renders a labeled <select> by default, connected to the active form adapter via context. The trigger can be replaced with a custom component via the Trigger slot. Error and hint states surface automatically — no prop wiring needed.
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
name | string | yes | — | Field name. Used as the form state key and to derive element IDs. |
label | string | — | Label text rendered above the select. | |
hint | string | — | Helper text shown below the select when no error is present. | |
placeholder | string | — | Placeholder text for the select trigger. | |
options | SelectOption<T>[] | — | Array of static options. Ignored when loadOptions is set. | |
loadOptions | (search: string) => Promise<SelectOption<T>[]> | — | Async function to fetch options. Called on mount (empty string) and on search input changes (debounced). | |
searchable | boolean | false | Renders a search input above the options list. | |
debounceMs | number | 300 | Debounce delay (ms) for loadOptions calls. | |
disabled | boolean | false | Disables the select. Inherits from StackFormProvider if not set. | |
loading | boolean | false | Replaces the select with a skeleton shimmer. | |
required | boolean | false | Marks the field as required. Appends * to the label. | |
classNames | SelectFieldClassNames | — | Tailwind class overrides per slot. Stacks with provider and core classes. | |
slots | SelectFieldSlots | — | Component overrides per slot. First non-null wins: field → provider → default. | |
slotProps | { wrapper?, label?, error?, hint? } | — | Extra props passed to each slot. Field-level replaces provider-level per key. | |
onValueChange | (value: T) => void | — | Called after onChange with the new value. | |
validate | (value: T) => string | undefined | Promise<string | undefined> | — | Field-level validator. Runs on blur. |
SelectOption
Each item in the options array follows this interface:
interface SelectOption<T = string> {
value: T
label: string
disabled?: boolean
group?: string
}Slots
| Slot | Prop interface | Description |
|---|---|---|
Wrapper | WrapperSlotProps | Outer container element |
Label | LabelSlotProps | Label element |
Trigger | SelectTriggerSlotProps | The trigger/dropdown button element |
Option | SelectOptionSlotProps | Individual option element |
Error | ErrorSlotProps | Error message |
Hint | HintSlotProps | Hint/helper text |
EmptyState | EmptyStateSlotProps | Shown when options list is empty |
LoadingState | LoadingStateSlotProps | Shown while loadOptions is fetching |
SelectTriggerSlotProps
| Prop | Type | Description |
|---|---|---|
id | string | ID for the trigger element |
name | string | Field name |
value | string | Raw selected value string |
selectedLabel | string | undefined | Human-readable label of the selected option. Use this for display — falls back to value if the selected option isn't found in the options list. |
placeholder | string | undefined | Placeholder text shown when nothing is selected |
disabled | boolean | undefined | Whether the trigger is disabled |
aria-describedby | string | undefined | IDs of associated error/hint elements |
aria-invalid | boolean | undefined | Set when the field has an error |
className | string | undefined | Class name from classNames.trigger |
classNames
| Key | Applied to |
|---|---|
wrapper | Outer container |
label | Label element |
input | Native select element (base class inherited from field wrapper) |
error | Error message |
hint | Hint text |
trigger | The select trigger/dropdown button |
option | Individual option element |
optionGroup | Option group container |
emptyState | Empty state message |
loadingState | Loading state container |
listbox | Options list container |
Examples
With error
Errors surface automatically from the form adapter. Trigger one manually to test:
form.setError('category', { message: 'Please select a category' })
<SelectField
name="category"
label="Category"
placeholder="Choose a category"
options={[
{ value: 'tech', label: 'Technology' },
{ value: 'health', label: 'Health' },
{ value: 'finance', label: 'Finance' },
]}
/>With hint
<SelectField
name="timezone"
label="Timezone"
hint="Your local timezone for scheduling."
placeholder="Select timezone"
options={[
{ value: 'utc', label: 'UTC' },
{ value: 'est', label: 'Eastern' },
{ value: 'cst', label: 'Central' },
{ value: 'pst', label: 'Pacific' },
]}
/>Static options with groups
Use the group property to organize options under <optgroup> headers:
<SelectField
name="location"
label="Office location"
options={[
{ value: 'sf', label: 'San Francisco', group: 'North America' },
{ value: 'nyc', label: 'New York', group: 'North America' },
{ value: 'london', label: 'London', group: 'Europe' },
{ value: 'paris', label: 'Paris', group: 'Europe' },
]}
/>Async options
Use loadOptions to fetch options dynamically. The function is called on mount and on search input changes. Omit the options prop when using loadOptions:
<SelectField
name="user"
label="Assign to user"
searchable
debounceMs={400}
loadOptions={async (search) => {
const res = await fetch(`/api/users?q=${encodeURIComponent(search)}`)
return res.json()
}}
/>With custom Trigger slot
Replace the Trigger slot to control how the closed select looks. Use selectedLabel (not value) to display the human-readable option label:
import type { SelectTriggerSlotProps } from '@stackform/core'
function CustomTrigger({
selectedLabel,
placeholder,
disabled,
className,
...rest
}: SelectTriggerSlotProps) {
return (
<button
type="button"
disabled={disabled}
className={className}
{...rest}
>
<span>{selectedLabel ?? placeholder}</span>
▾
</button>
)
}
<SelectField
name="country"
label="Country"
placeholder="Select a country"
options={[
{ value: 'us', label: 'United States' },
{ value: 'uk', label: 'United Kingdom' },
]}
slots={{ Trigger: CustomTrigger }}
/>With slot override
Replace the EmptyState slot with a custom component. Slot components receive only display-level props.
import type { EmptyStateSlotProps } from '@stackform/ui'
function CustomEmpty({ message, className }: EmptyStateSlotProps) {
return (
<div className={className}>
<p className="text-center text-sm text-gray-500">
No results found
</p>
<p className="text-center text-xs text-gray-400">
Try a different search term
</p>
</div>
)
}
<SelectField
name="search"
label="Search items"
searchable
loadOptions={async (search) => {
const res = await fetch(`/api/search?q=${search}`)
return res.json()
}}
slots={{ EmptyState: CustomEmpty }}
/>