Select
Choose registered values from a bounded set, with truthful labels, native form serialization, single/multiple selection and explicit request recovery.
Choose a selection model
Use Select when the user needs registered choices and an input-shaped compact control. Prefer Radio when comparison benefits from keeping choices visible; use a native select when its platform UI suits the task. Combobox supports editing/filtering a choice set; Autocomplete suggests text. Command Menu runs actions rather than setting a field value.
Choice count alone does not determine the component. Consider label length, comparison, grouping, familiarity, device input and the cost of hiding options. A selected value, highlighted option and applied result are different states.
- Provide a persistent field label and a meaningful empty prompt. A placeholder is not a label.
- Use stable unique values and useful labels. Validate availability and permission separately from the control’s appearance.
- Keep a visible disabled choice when it explains availability; explain alternatives without inventing source/review authority.
Anatomy and dimensions
| Part | Actual shared contract |
|---|---|
| Root | No visible DOM node. Owns value/open/multiple/form registration and installed field integration. |
| Trigger / Value / Icon | Default button, span and decorative icon. Trigger is at least 44px high, full width with 12px inline padding, 2px boundary and wrapping label. Native button ref; Value span ref. |
| Portal / Positioner | Floating composition outside clipping ancestors; positioner owns side/align/collision offsets and stacking. |
| Popup / List | Popup default div with Element ref matching installed render contract. Bounded single border surface, maximum 320px/available height and viewport width. List is a div with actual scrolling and listbox semantics when present. Older direct-item composition remains supported. |
| Item / text / indicator | Default div option with at least 44px height, 8px × 12px padding and selected checkmark. Highlight gets a separate inset 2px outline; selected text is bold. |
| Group / label / separator | Installed accessible grouping and associated labels; separator is nonselectable punctuation. Group labels use muted foreground and small text. |
| Focus / alternative rendering | Trigger focus is a separate 2px outline. State callbacks retain shared classes. Forced colors retain boundaries/highlight/selection; no interpolated state colors or popup animation. |
Real values, FormData and retained preview
Choose single reading spacing and optional multiple collections, then submit. Required correction focuses the actual trigger. The real FormData snapshot contains stable spacing and repeated collection entries; explicit completion changes actual preview spacing and records. Failure/cancellation retain the draft and loaded preview. Choosing no collections produces a recoverable empty local result.
Selectors are disabled during the request. Read-only and unavailable modes reject editing, and an independent restoration control recovers access without discarding values. RTL reaches both the inline form and its portal. The English-only fixture is local page memory and proves no remote write, source review or durable storage.
Loaded local reading preview
Loaded spacing: roomy. Loaded collections: examples.
- English-only local examples record
Actual submitted FormData entries: No form submitted.
Loaded defaults remain until explicit local completion.
'use client';
import { useRef, useState } from 'react';
import { Button, DirectionProvider, Select, SelectGroup, SelectGroupLabel, SelectItem, SelectList, SelectPopup, SelectPortal, SelectPositioner, SelectSeparator, SelectTrigger, SelectValue } from '@mobtranslate/ui';
const spacingOptions=[{value:'roomy',label:'Roomy reading'},{value:'compact',label:'Compact reading'},{value:'unavailable',label:'Reviewed spacing unavailable'}];
const collectionOptions=[{value:'examples',label:'Local examples'},{value:'notes',label:'Local notes'},{value:'references',label:'Local references'}];
const records=collectionOptions.map(option=>({...option,text:`English-only ${option.label.toLowerCase()} record`}));
export function SelectPreferencesExample() {
const [spacing,setSpacing]=useState<string | null>(null);
const [collections,setCollections]=useState<string[]>([]);
const [disabled,setDisabled]=useState(false);
const [readOnly,setReadOnly]=useState(false);
const [rtl,setRtl]=useState(false);
const [pending,setPending]=useState(false);
const [error,setError]=useState('');
const [requested,setRequested]=useState<{spacing:string;collections:string[]} | null>(null);
const [applied,setApplied]=useState({spacing:'roomy',collections:['examples']});
const [payload,setPayload]=useState('No form submitted.');
const [status,setStatus]=useState('Loaded defaults remain until explicit local completion.');
const trigger=useRef<HTMLButtonElement>(null);
const heading=useRef<HTMLHeadingElement>(null);
const direction=rtl ? 'rtl' : 'ltr';
function resolve(outcome:'complete'|'fail'|'cancel') {
if (!pending || !requested) return;
if (outcome==='complete') {setApplied(requested);setStatus('Local preferences applied to the actual preview. No remote save occurred.');}
if (outcome==='fail') setStatus('Local preference request failed. Loaded preview and draft values remain; submit again to retry.');
if (outcome==='cancel') setStatus('Local preference request cancelled. Loaded preview and draft values remain.');
setPending(false);setRequested(null);heading.current?.focus();
}
return <div data-select-lab className="space-y-4">
<div className="flex flex-wrap gap-3"><Button type="button" variant="outline" disabled={pending} aria-pressed={disabled} onClick={()=>setDisabled(value=>!value)}>Disable local selectors</Button><Button type="button" variant="outline" disabled={pending} aria-pressed={readOnly} onClick={()=>setReadOnly(value=>!value)}>Make local selectors read only</Button><Button type="button" variant="outline" aria-pressed={rtl} onClick={()=>setRtl(value=>!value)}>Use RTL local selectors</Button><Button type="button" variant="outline" disabled={pending} onClick={()=>{setDisabled(false);setReadOnly(false);setError('');}}>Restore editable local selectors</Button></div>
<DirectionProvider direction={direction}><form dir={direction} noValidate className="space-y-4" onSubmit={event=>{
event.preventDefault();if(pending || disabled) return;
if(!spacing) {setError('Choose reading spacing before submitting local preferences.');trigger.current?.focus();return;}
const data=new FormData(event.currentTarget);setPayload(JSON.stringify([...data.entries()]));
setRequested({spacing:String(data.get('spacing')),collections:data.getAll('collection').map(String)});setPending(true);setError('');setStatus('Local preferences requested; loaded preview remains. Choose an explicit outcome.');heading.current?.focus();
}}>
<label id="select-spacing-label" htmlFor="select-spacing-trigger" className="block font-semibold">Local reading spacing</label>
<Select name="spacing" required items={spacingOptions} value={spacing} onValueChange={value=>{setSpacing(value);setError('');}} disabled={disabled || pending} readOnly={readOnly}>
<SelectTrigger ref={trigger} id="select-spacing-trigger" aria-labelledby="select-spacing-label" aria-describedby={error ? 'select-spacing-help select-spacing-error' : 'select-spacing-help'} aria-invalid={!!error} className={state=>state.open ? 'local-spacing-open' : undefined}><SelectValue>{value=>value ? spacingOptions.find(option=>option.value===value)?.label : 'Choose local reading spacing'}</SelectValue></SelectTrigger>
<SelectPortal><SelectPositioner alignItemWithTrigger={false} sideOffset={8} collisionPadding={8}><SelectPopup dir={direction}><SelectList aria-label="Local reading spacing choices"><SelectGroup><SelectGroupLabel>Available layouts</SelectGroupLabel>{spacingOptions.slice(0,2).map(option=><SelectItem key={option.value} value={option.value} label={option.label}>{option.label}</SelectItem>)}</SelectGroup><SelectSeparator /><SelectGroup><SelectGroupLabel>Unavailable layout</SelectGroupLabel><SelectItem value="unavailable" disabled>Reviewed spacing unavailable</SelectItem></SelectGroup></SelectList></SelectPopup></SelectPositioner></SelectPortal>
</Select>
<p id="select-spacing-help">Required draft choice. A selected label is not a loaded result, review or permission.</p>
{error && <p id="select-spacing-error" role="alert">{error}</p>}
<label id="select-collection-label" htmlFor="select-collection-trigger" className="block font-semibold">Local preview collections</label>
<Select multiple name="collection" items={collectionOptions} value={collections} onValueChange={setCollections} disabled={disabled || pending} readOnly={readOnly}>
<SelectTrigger id="select-collection-trigger" aria-labelledby="select-collection-label" aria-describedby="select-collection-help"><SelectValue>{values=>values.length ? values.map((value:string)=>collectionOptions.find(option=>option.value===value)?.label).join(', ') : 'Choose any local collections'}</SelectValue></SelectTrigger>
<SelectPortal><SelectPositioner alignItemWithTrigger={false} sideOffset={8} collisionPadding={8}><SelectPopup dir={direction}><SelectList aria-label="Local preview collection choices">{collectionOptions.map(option=><SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}</SelectList></SelectPopup></SelectPositioner></SelectPortal>
</Select>
<p id="select-collection-help">Optional multiple values serialize under the repeated collection name. Choosing none produces an empty local preview after completion.</p>
<div className="flex flex-wrap gap-3"><Button type="submit" disabled={pending || disabled}>Submit local select preferences</Button><Button type="button" variant="outline" disabled={pending || disabled || readOnly} onClick={()=>{setSpacing(null);setCollections([]);setError('');trigger.current?.focus();}}>Clear local select choices</Button></div>
</form></DirectionProvider>
<h3 ref={heading} tabIndex={-1} data-select-preview-heading className="font-semibold">Loaded local reading preview</h3>
<p data-select-loaded>Loaded spacing: {applied.spacing}. Loaded collections: {applied.collections.join(', ') || 'none'}.</p>
<ul data-select-records className="guidance-list" style={{lineHeight:applied.spacing==='compact' ? 1.4 : 1.8}}>{records.filter(record=>applied.collections.includes(record.value)).map(record=><li key={record.value}>{record.text}</li>)}</ul>
{!applied.collections.length && <p>No local collections selected. Choose a collection and submit to recover an actual preview.</p>}
<p data-select-payload style={{overflowWrap:'anywhere'}}>Actual submitted FormData entries: {payload}</p>
{pending && <div className="flex flex-wrap gap-3"><Button type="button" onClick={()=>resolve('complete')}>Complete local select preferences</Button><Button type="button" variant="outline" onClick={()=>resolve('fail')}>Fail local select preferences</Button><Button type="button" variant="outline" onClick={()=>resolve('cancel')}>Cancel local select preferences</Button></div>}
<p role="status" data-select-status>{status}</p>
</div>;
}
Value display and selection ownership
- These contracts describe @base-ui-components/react 1.0.0-rc.0. Current upstream documentation can contain different props. SelectValue has no dedicated placeholder prop in this pin.
- A literal SelectValue child always replaces the display, even after selection. Use a value render function for an empty prompt plus selected labels, or omit children and provide Root items/itemToStringLabel. The previous fixed-child demos concealed actual selection.
- Use value with onValueChange for controlled data; defaultValue initializes uncontrolled data. Use null for empty single selection and an array for multiple selection; do not switch ownership modes mid-lifecycle.
- Root items is label metadata, not automatic option generation. Render matching Item values. Values, display labels, serialization and comparison must agree, especially for objects.
- The pinned root can keep closed option markup mounted to register values/labels after a programmatic value change. Its Positioner becomes hidden; closed DOM presence is not an open popup. Preserve that hidden contract and inspect the active accessibility/focus tree. Draft ownership belongs outside the popup. Closing/unmounting it must not lose the task’s selected values or confuse selection with application completion.
Installed root API
| Prop | Ownership and defaults |
|---|---|
| value / defaultValue / onValueChange | Generic scalar or multiple array. Single change can return null; multiple change returns an array. Callback includes installed change-event details. |
| multiple | false default. Arrays represent multiple values; Root emits repeated named hidden inputs for nonempty selections. |
| name / id / inputRef | Native form identity, hidden control id and input ref. Label the visible trigger separately; do not focus an aria-hidden input as the user’s task target. |
| required / disabled / readOnly | false defaults. Required native registration is available; the lab deliberately uses noValidate plus visible caller validation. Disabled controls reject interaction and omit values; read-only retains serialization while preventing edits. |
| items | Record or readonly label/value array for display metadata. It does not create the rendered options. |
| itemToStringLabel / itemToStringValue | Object display and form conversion. A value/label object shape has installed defaults; provide explicit conversion for other shapes. |
| isItemEqualToValue | Object.is default; customize object identity when stable values are recreated. Avoid colliding form serialization. |
| open / defaultOpen / onOpenChange | Controlled/uncontrolled disclosure; false initial default. Coordinate external availability changes with closing/focus recovery. |
| onOpenChangeComplete / actionsRef | Animation-completion callback and imperative unmount handle. Supplying actionsRef changes automatic unmount ownership; consumers must finish that lifecycle. |
| modal / highlightItemOnHover | true defaults. Modal restricts outside interaction/scroll while open; hover highlighting does not itself prove committed selection. |
Installed part API and composition
| Export | Contract |
|---|---|
| SelectTrigger | Native-button/composition props, installed trigger state and actual button ref. Preserve label, description, error and expanded relationships. Includes the shared decorative Icon. |
| SelectValue | Span props/ref and children(value) formatter or ordinary overriding children. Default resolution uses registered values and Root metadata. |
| SelectPortal / SelectBackdrop | Installed portal mounting/container and optional backdrop. Backdrop visual styling is caller-owned. |
| SelectPositioner | side/align/offset/collision/anchor positioning. alignItemWithTrigger defaults true and can overlap the selected label with the trigger for mouse input. The lab explicitly sets false for anchored below/above placement. |
| SelectPopup / SelectList | Compose one surface and one scroll list. Shared max dimensions apply; custom render must retain roles, focus and geometry contracts. |
| SelectItem | value, disabled, label (typeahead text), native/composition props and state callback. Shared wrapper supplies ItemText and selected Indicator automatically. Do not nest buttons/links inside an option. |
| SelectGroup / SelectGroupLabel / SelectSeparator | Accessible group label association and noninteractive separator. Preserve full names and DOM ordering. |
| SelectIcon / SelectItemText / SelectItemIndicator | Raw installed parts for deliberate advanced rendering. Avoid duplicating the automatic trigger icon/item wrappers when composing them. |
| SelectArrow / SelectScrollUpArrow / SelectScrollDownArrow | Optional installed anchor and hover-scroll parts, with caller-owned placement/sizing. Scroll arrows are divs, unavailable for touch input, and support keepMounted; retain ordinary keyboard/native scrolling. The lab uses the bounded List instead. |
| className / style / render / ref | Styled parts preserve installed state callbacks and native composition. Root has no visible class target; apply layout to actual parts or an ordinary containing element. |
Keyboard, pointer and focus
- Tab reaches the labelled trigger; native Enter/Space and arrow interaction follow the installed select-only implementation. Once open, inspect actual highlighted versus selected state, typeahead, available-option navigation and first/last behavior. Do not reinterpret it as editable text or a command menu.
- Escape closes the popup and returns to a surviving trigger. Multiple selection can remain open for further choices; an explicit Escape/outside dismissal leaves chosen values intact.
- Typeahead uses Item label or text. Group labels/separators are not choices. Do not put additional focusable commands inside a listbox option.
- Disabled choices can remain arrow-highlightable in this pin; highlighting does not permit Enter/Space selection. Disabled/read-only enforcement must hold for pointer and keyboard. Keep restoration/recovery outside an unavailable selector.
- Long lists need real scrolling with the final option reachable and focus/highlight kept visible. Touch scrolling, platform-specific announcement and object/autofill behavior need scoped acceptance evidence.
States and recovery invariants
| State | Required outcome |
|---|---|
| Empty / required | Show the empty prompt and persistent label. On invalid submit connect visible error and focus the trigger; preserve other choices. |
| Selected / multiple | Display the actual labels and corresponding selected state. Multiple native form entries retain the repeated name; empty arrays do not invent values. |
| Highlighted / open | Separate navigation focus from stored choice and from applied preview. Preserve readable text, current checkmark and independent highlight boundary. |
| Unavailable / read-only | Reject editing. Read-only values remain form-successful; disabled values are omitted. The workspace patch repairs disabled repeated inputs in both CJS and ESM. |
| Requested / pending | Capture actual submitted values, prevent duplicate submissions, retain the loaded preview and provide reachable outcome controls. |
| Failure / cancelled | Preserve selected draft and loaded result. Retry must still consume actual valid form values; cancellation is not success. |
| Completed / empty result | Only completion updates the real records/spacing. No selected collections produces explicit recovery guidance. |
| Stale/removed option / remote choices | Validate against current availability, retain provenance and reconcile identity. This fixed fixture does not certify remote result races or grant source permission. |
Common failures and release verification
- Do not repeat fixed placeholder children that conceal all selected values. Do not replace useful labels with raw database identifiers when items metadata/formatters can supply the real label.
- Do not assume disabled multiple selection is omitted just because the trigger is disabled; verify actual FormData. A serialized value is not backend authorization.
- Do not force a select to serve a search input, arbitrary text entry or nested command menu. Choose and document the correct model.
- Run test:select, checked-source/token freshness, UI/styleguide typechecks, lint, production build and the route/fragment crawl. Verify actual form entries, disabled/read-only/multiple behavior, selected labels, error focus and preview outcomes.
- Test both themes, 320px reflow, bounded positioning, RTL, reduced motion, forced colors, zoom/text spacing and a named assistive-technology/browser pair. DOM/browser checks alone are not whole-library certification.