Browse docs
Browse docs
A dropdown enum picker. Pick one value (or several, with multiple) from a static options list. Optimised for the 3–10 option case — plan tiers, statuses, roles, config enums.
Select is deliberately narrower than <Autocomplete>: no search input, no async loadOptions, no free-solo. If the user needs to filter a long or remote list, reach for Autocomplete. If the choice set is a short, known enum, reach for Select. The two share the same { value, label, disabled? } option shape, so switching between them is a rename, not a rewrite.
import { Select } from '@dashforge/tw';
<Select
name="plan"
label="Plan"
options={[
{ value: 'free', label: 'Free' },
{ value: 'pro', label: 'Pro' },
{ value: 'team', label: 'Team' },
]}
/>import { DashForm } from '@dashforge/forms';
import { Select, Button } from '@dashforge/tw';
<DashForm onSubmit={onSubmit}>
<Select
name="plan"
label="Plan"
options={PLANS}
placeholder="Choose a plan…"
required
/>
<Button type="submit" color="primary">Continue</Button>
</DashForm>Inside a <DashForm> the field self-registers with the bridge — the selected value commits on pick, validation errors replace the helper text. Outside a form, drive it with value + onChange (controlled) or defaultValue (uncontrolled).
import { Select } from '@dashforge/tw';
const PLANS = [
{ value: 'free', label: 'Free' },
{ value: 'pro', label: 'Pro' },
{ value: 'team', label: 'Team' },
{ value: 'enterprise', label: 'Enterprise' },
];
<Select
name="plan"
label="Plan"
placeholder="Choose a plan…"
options={PLANS}
/>const PLANS = [
{ value: 'free', label: 'Free' },
{ value: 'pro', label: 'Pro' },
{ value: 'team', label: 'Team' },
{ value: 'enterprise', label: 'Enterprise' },
];
<Select name="plan" label="Plan" placeholder="Choose a plan…" options={PLANS} />Picking an option commits the value, closes the popover, and returns focus to the trigger. The chosen label shows in the trigger; the selected row carries a checkmark.
import { Select } from '@dashforge/tw';
const PERMISSIONS = [
{ value: 'read', label: 'Read' },
{ value: 'write', label: 'Write' },
{ value: 'deploy', label: 'Deploy' },
{ value: 'admin', label: 'Admin' },
];
<Select
name="permissions"
label="Permissions"
placeholder="Pick one or more…"
multiple
options={PERMISSIONS}
defaultValue={['read', 'write']}
/><Select
name="permissions"
label="Permissions"
placeholder="Pick one or more…"
multiple
options={PERMISSIONS}
defaultValue={['read', 'write']}
/>In multiple mode the bridge value is an array. Selected items render as removable chips in the trigger, and the popover stays open so the user can toggle several picks in a row. Each chip's × removes just that value.
Per-option disabled grays the row and blocks selection (and skips it during keyboard navigation and type-ahead):
<Select
name="tier"
label="Tier"
options={[
{ value: 'starter', label: 'Starter' },
{ value: 'growth', label: 'Growth' },
{ value: 'legacy', label: 'Legacy (unavailable)', disabled: true },
]}
/>value accepts string | number. Pass numeric IDs and TypeScript narrows onChange to the numeric union:
<Select
name="priority"
label="Priority"
options={[
{ value: 1, label: 'Low' },
{ value: 2, label: 'Medium' },
{ value: 3, label: 'High' },
]}
onChange={(value) => {/* value: 1 | 2 | 3 */}}
/><Select size="sm" name="x" options={OPTIONS} />
<Select size="md" name="x" options={OPTIONS} /> {/* default */}
<Select size="lg" name="x" options={OPTIONS} />
<Select layout="inline" name="x" label="Region" options={OPTIONS} />When options is [], the popover renders the emptyState fallback (default "No options"):
<Select name="assignee" label="Assignee" options={[]} emptyState="No teammates yet" />label is a ReactNode, not a string — so you can pass a <Trans> / <FormattedMessage> for reactive translation without wrapping the whole array in a useMemo:
const STATUSES = [
{ value: 'open', label: <Trans i18nKey="status.open" /> },
{ value: 'closed', label: <Trans i18nKey="status.closed" /> },
];
<Select name="status" label={<Trans i18nKey="field.status" />} options={STATUSES} />Keyboard type-ahead matches against plain-string labels only — a first-letter jump can't see into an i18n element. For enums where type-ahead matters, prefer string labels (or a
t()call that returns a string).
Configure <Select> defaults application-wide via Option C.
import { patchTheme } from '@dashforge/tw-theme';
patchTheme({
components: {
Select: {
defaults: { size: 'md', layout: 'stacked', fullWidth: true },
},
},
});Configurable axes (SelectVariantProps):
| Axis | Type | Notes |
|---|---|---|
size | 'sm' | 'md' | 'lg' | Trigger height + padding + font-size. |
layout | 'stacked' | 'inline' | Label position (above vs left of the trigger). |
fullWidth | boolean | Stretch root + trigger to container width. |
Non-visual axes (name, rules, options, multiple, value, defaultValue, onChange, label, helperText, error, required, disabled, placeholder, emptyState, access, visibleWhen) are not theme-configurable — per-instance semantics.
Precedence chain (lowest → highest):
defaultVariants from the internal selectVariants recipe.theme.components.Select.defaults (application-wide).<Select size="lg" fullWidth />) — wins over theme.sx — appended to the trigger and wins over conflicting classes via tailwind-merge.Reactivity: useComponentDefaults('Select') subscribes to the theme store — patchTheme re-renders every mounted instance inheriting the changed axis.
| Prop | Type | Default | Description |
|---|---|---|---|
name | string | — | Field name — required at the TypeScript level. Consistent with the rest of the tw form catalog: enforcing name at compile time prevents the "silent no-op" family of bugs (see #113). |
options | readonly SelectOption<V>[] | — | The static options list. [] renders the empty state in the popover. Consumers typically define this at module scope for stability, or useMemo it if the list depends on props / i18n. |
access | AccessRequirement | — | RBAC gate. See useAccessState. |
defaultValue | V | V[] | null | — | Uncontrolled initial value. Same shape rules as value. |
disabled | boolean | — | Grays out the trigger + popover, blocks keyboard interaction. |
emptyState | ReactNode | 'No options' | Fallback rendered inside the popover when options is empty. |
error | boolean | — | Force error state without consulting the bridge — useful for server-side / async validation the bridge doesn't own. |
fullWidth | boolean | — | Stretches the root + trigger to w-full. |
helperText | ReactNode | — | Descriptive line under the trigger. Replaced by the bridge's validation error when the field is invalid. |
label | ReactNode | — | Text or node shown above (or to the left of, in inline layout) the trigger. |
layout | 'stacked' | 'inline' | 'stacked' | Layout — stacked (label above trigger) or inline (label on the left, trigger on the right). Matches Autocomplete. |
multiple | boolean | false | When true, allows multiple selections. value / defaultValue become arrays; onChange receives the array signature; the trigger renders a chip list of selected items.Additive — single-select consumers ignore this and get the classic enum picker behaviour. |
onBlur | () => void | — | Fired on trigger blur (after the popover closes). |
onChange | SelectChangeHandler<V> | SelectMultiChangeHandler<V> | — | Called on selection change. Two arguments — the primitive value that will be committed to the form, AND the full SelectOption object (for consumers who put custom fields on the option and want them at hand in the callback without an extra lookup).TypeScript picks the single- or multi-select signature based on the runtime multiple prop via the discriminated union at the type layer. |
placeholder | ReactNode | 'Select…' | Placeholder text shown in the trigger when no value is selected. |
required | boolean | — | Renders the red * required marker and forwards to the native required attribute. Distinct from rules.required — this one is purely visual + native; the bridge validation runs independently. |
rules | unknown | — | React Hook Form rule set. Forwarded to bridge.register(name, rules) when inside a <DashFormProvider>. Ignored outside a form. Untyped by design here — the rules shape belongs to whatever RHF version the consumer app pins, so we pass through as-is (mirrors Autocomplete's rules?: unknown). |
size | 'md' | 'sm' | 'lg' | 'md' | Density knob — matches Autocomplete + TextField / NumberField. |
slotProps | SelectSlotProps | — | Per-slot overrides. |
sx | string | — | Utility-class shortcut for slotProps.trigger.className — mirrors the Dashforge idiom on other primitives (Button, Dialog, Link). |
testId | string | — | data-testid forwarded to the root wrapper. |
value | V | V[] | null | — | Controlled selection. For single-select, the primitive value of the currently chosen option (or null for nothing selected). For multi-select (multiple: true), the array of selected values.When omitted AND the field is not bridge-managed, the component runs uncontrolled and manages selection internally. |
visibleWhen | (engine: Engine) => boolean | — | Reactive visibility predicate. Falsy → renders null. |
<Select> is a compound component. Each slot accepts a { className?: string } override via slotProps:
| Slot | Purpose |
|---|---|
root | Outer wrapper (label + trigger + helper/error). |
label | The <label> element above (or left of) the trigger. |
requiredMark | The red * next to the label when required. |
trigger | The button surface displaying the current value. |
triggerText | The selected label text inside the trigger. |
triggerPlaceholder | The muted placeholder when nothing is selected. |
chevron | The caret glyph on the trigger (rotates when open). |
popover | The floating panel holding the option list. |
listBox | The <ul> inside the popover wrapping the options. |
listItem | A single option row. |
listItemIndicator | The checkmark on the currently-selected row. |
emptyState | The fallback shown when options is empty. |
helperText | Helper text line, when not in error state. |
errorText | Helper text line, when in error state. |
chipsList | Container for the selected chips (multi-select only). |
chip | An individual selected chip in multi-select. |
chipRemove | The × button on a selected chip. |
Use sx for a trigger-level class override; use slotProps to reach a specific inner element (e.g. popover max-height, listItem hover color, chip background).
↑/↓ move focus (wrapping, skipping disabled rows), Home/End jump to the first/last enabled option, Enter/Space open or commit, Escape closes. Tab is left untouched so native focus flow is preserved.@radix-ui/react-popover for outside-click / Escape dismissal and collision-aware placement. The popover matches the trigger width via a --trigger-width CSS variable.role="combobox" with aria-haspopup="listbox"; the list is role="listbox" with role="option" rows carrying aria-selected / aria-disabled. Roving focus is driven with aria-activedescendant, so DOM focus stays on the trigger.V extends string | number is inferred from options, so value and onChange narrow to the exact union of the literals you pass.