StackForm
Components

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

PropTypeRequiredDefaultDescription
namestringyesField name. Used as the form state key and to derive element IDs.
labelstringLabel text rendered above the select.
hintstringHelper text shown below the select when no error is present.
placeholderstringPlaceholder text for the select trigger.
optionsSelectOption<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).
searchablebooleanfalseRenders a search input above the options list.
debounceMsnumber300Debounce delay (ms) for loadOptions calls.
disabledbooleanfalseDisables the select. Inherits from StackFormProvider if not set.
loadingbooleanfalseReplaces the select with a skeleton shimmer.
requiredbooleanfalseMarks the field as required. Appends * to the label.
classNamesSelectFieldClassNamesTailwind class overrides per slot. Stacks with provider and core classes.
slotsSelectFieldSlotsComponent 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) => voidCalled 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

SlotProp interfaceDescription
WrapperWrapperSlotPropsOuter container element
LabelLabelSlotPropsLabel element
TriggerSelectTriggerSlotPropsThe trigger/dropdown button element
OptionSelectOptionSlotPropsIndividual option element
ErrorErrorSlotPropsError message
HintHintSlotPropsHint/helper text
EmptyStateEmptyStateSlotPropsShown when options list is empty
LoadingStateLoadingStateSlotPropsShown while loadOptions is fetching

SelectTriggerSlotProps

PropTypeDescription
idstringID for the trigger element
namestringField name
valuestringRaw selected value string
selectedLabelstring | undefinedHuman-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.
placeholderstring | undefinedPlaceholder text shown when nothing is selected
disabledboolean | undefinedWhether the trigger is disabled
aria-describedbystring | undefinedIDs of associated error/hint elements
aria-invalidboolean | undefinedSet when the field has an error
classNamestring | undefinedClass name from classNames.trigger

classNames

KeyApplied to
wrapperOuter container
labelLabel element
inputNative select element (base class inherited from field wrapper)
errorError message
hintHint text
triggerThe select trigger/dropdown button
optionIndividual option element
optionGroupOption group container
emptyStateEmpty state message
loadingStateLoading state container
listboxOptions 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 }}
/>