Select and autocomplete
Fixed choices, searchable selection and free-form suggestions.
Quick start
After the application setup, copy this component into your application. It imports only public packages; the complete live demo later on this page also uses documentation layout helpers.
'use client';
import { useState } from 'react';
import { Select } from '@fayegram-ai/faye-ui/controls/select';
import { FieldLayout } from '@fayegram-ai/faye-ui/controls/field-layout';
const places = [{ value: 'coast', label: 'By the coast' }, { value: 'garden', label: 'In the gardens' }];
export function PlacePicker() {
const [place, setPlace] = useState<string | null>('coast');
return <FieldLayout label="Where would you like to go?" description="Choose one place.">
{attributes => <Select.Root name="place" items={places} value={place} onValueChange={setPlace}>
<Select.Trigger {...attributes}><Select.Value /></Select.Trigger>
<Select.Portal><Select.Positioner><Select.Popup><Select.List>
{places.map(item => <Select.Item key={item.value} value={item.value}>
<Select.ItemText>{item.label}</Select.ItemText><Select.ItemIndicator />
</Select.Item>)}
</Select.List></Select.Popup></Select.Positioner></Select.Portal>
</Select.Root>}
</FieldLayout>;
}API reference: Select, Field Layout.
Usage contract
Select does not accept arbitrary text. Combobox selects values while filtering items. Autocomplete can preserve free-form input. Generic collection roots remain direct exports to preserve value types. Network loading and stale-response handling belong to the application or extension.
Customization
Use semantic tokens for visual changes, public component parts for structural customization, and explicit props for instance behavior. The surrounding site lets you compare light and dark, different brands and density.
Editing before hydration
Server-rendered Faye UI Input, Combobox.Input and Autocomplete.Input preserve text edited before client hydration by replaying it through their existing change callbacks during initialization. The accepted edit retains its focus and caret. Combobox query text remains separate from its committed value: typing a city name does not submit a different selected city until an option is chosen. Autocomplete can retain free-form text.
Controlled callers keep authority: store the requested change to accept it, or retain the controlled prop to reject it. Later controlled updates replace the value normally. Disabled/read-only inputs are not replayed. The replay is a synthetic input event and does not grant trusted user activation. CSR has no server input to edit before its client module loads. Physical IME/autofill and assistive-technology behavior still require target-device review.
Source and verification
The examples below import actual public package entries. Full source is downloadable. Source coverage does not establish keyboard, hydration or browser compatibility; those require the application test suite.
Labels, anatomy and dimensions
When the editable input is the form control, use a native label with htmlFor/id or Field.Label. Combobox.Label labels the popup trigger; it is for a trigger-first composition, not the editable input. Clear and popup controls need their own action names.
Standalone Combobox/Autocomplete inputs have a border and focus ring. In an InputGroup, the group owns the boundary and focus ring, and the input fits inside its border. Use the same size variants on a composed group/input. Medium controls measure 36px comfortable and 32px compact; small 32/28px; large 44/36px. Single-line options use 32/28px rows, with room to grow for wrapping text. Coarse pointers use at least 44px targets.
Selection popups align to the trigger subject to the configured maximum and viewport space. Command menus use content width. Pending/error/empty states, cancellation and latest-result ownership remain application responsibilities; see asynchronous workflows for the executable pattern.
Dropdown indicators
Picker defaults use licensed Lucide ChevronDown, X and Check SVG geometry, with a 16px frame and 2-unit stroke in a 24-unit viewBox. The chevron frame sits 13px inside the default one-pixel border in either direction, centered vertically. Select, Combobox and Autocomplete keep a fixed trailing indicator slot; custom children still override the default artwork. Give separate clear and popup actions accessible names. Decorative SVGs do not add characters to those names.
NativeSelect keeps the native select element, ref, options, form behavior and keyboard interaction. A decorative span/SVG wrapper positions its arrow consistently. Consumer classes, profiles and ordinary props still target the select; CSS relying on it being a direct child may need to account for the wrapper. Multiple selects and visible listboxes have no added wrapper/arrow. Forced-colors mode restores the browser's native arrow.
Picker defaults explicitly import ChevronDown, X and Check from pinned @lucide/icons@1.52.0 and render them through @fayegram-ai/faye-ui/core/icon. The renderer imports neither icon catalogue. Application artwork now uses the same system. The foundations chapter documents both artwork packs, legacy compatibility, accessibility and authoring. Applicable attribution and license notices are retained in the distributed package's LICENSE and the upstream icon package.
Select defaults to a popup below the trigger, aligned to its logical start with a 4px offset. The popup follows the anchor width and remains bounded by the available viewport. Set alignItemWithTrigger explicitly on Select.Positioner to opt into selected-item alignment. Combobox and Autocomplete popups use the same anchor-width bounds. A compound field shows one keyboard focus frame: the input group owns input focus, while clear/trigger buttons own their own focus.
Combobox.ChipRemove includes a compact close icon and a localized fallback label. Give it an item-specific aria-label, such as Remove Oslo, when the value is known. Children, render props and provider settings can replace the default artwork or label.
When Select.List is supplied, it owns the bounded scroll area. Optional Select.ScrollUpArrow and Select.ScrollDownArrow stay at the popup edges and scroll on hover when more options exist; both include replaceable chevron artwork. Touch input uses ordinary scrolling, and keyboard users retain arrow, Home and End navigation. Direct-item popup compositions remain supported.
Read-only pickers still allow browsing their options, while preserving the selected value. Combobox and Autocomplete clear actions become disabled, including custom-rendered clear controls. Fully disabled pickers show unavailable cursors and suppress action feedback. When a field is both disabled and read-only, its disabled surface token takes precedence, including inside input groups.
Native mobile selection
SelectField from controls/select is a convenience composition for single selection with unique, nonempty string values and string labels. Set nativeOnMobile to use a native <select> when the primary pointer is coarse; desktop keeps the themed Select popup. Set native to use a native picker on every device. The selected value, form name, disabled choices, required state and form reset are shared by both modes. value/onValueChange support controlled state; defaultValue supports local state. size, tone, variant, label/description attributes and refs target the active control.
import { SelectField } from '@fayegram-ai/faye-ui/controls/select';
<SelectField aria-label="Destination" name="destination" nativeOnMobile
defaultValue="coast"
items={[{value:'coast',label:'By the coast'},{value:'garden',label:'In the gardens'}]}/>The primary pointer is a capability check, rather than a screen-width or user-agent guess. Server rendering starts with the custom composition and resolves the native preference after hydration. Operating-system picker chrome belongs to the device; the closed control, its arrow and HTML options use the active theme. Use compound Select, Combobox or Autocomplete for multiple selection, object values, custom option content or searchable collections.
Plain and filled dropdowns keep their bottom border across tones. The native control and custom trigger use one inset keyboard focus ring. Inline native dropdowns retain the same 13px trailing chevron inset in either direction.
Suggestion lists and select popups use viewport positioning by default, including inside scrollable dialogs. Revealing an option keeps the locked document body in place. Set positionMethod="absolute" on the positioner only when an application-owned containing block requires it.
Live examples
Try the controls below, then read or copy the complete example to adapt it for your application.
Adaptive and native selection
Adaptive uses the native system picker when the primary pointer is coarse. Desktop keeps the themed popup.
Select
Searchable selection
Free-form autocomplete
Current suggestion:Autocomplete with dropdown actions
Multiple offices with removable chips
Grouped destinations
Scrollable selection
Read the complete example
'use client';
import { useId, useState } from 'react';
import { Select, SelectField } from '@fayegram-ai/faye-ui/controls/select';
import { Combobox } from '@fayegram-ai/faye-ui/controls/combobox';
import { Autocomplete } from '@fayegram-ai/faye-ui/controls/autocomplete';
import { Section, Result } from '../example';
import { Choice } from '../choice';
import { FieldLayout } from '@fayegram-ai/faye-ui/controls/field-layout';
import { NativeSelect } from '@fayegram-ai/faye-ui/controls/native-select';
import { Grid, QueryContainer, Stack } from '@fayegram-ai/faye-ui/layout/layout';
import { Button } from '@fayegram-ai/faye-ui/controls/button';
import './pickers.css';
const cities = ['Amsterdam', 'Berlin', 'Copenhagen', 'Lisbon', 'Oslo', 'Stockholm'];
export default function Pickers() {
const id = useId(), [city, setCity] = useState<string | null>('Oslo'), [query, setQuery] = useState('');
const [pickerMode, setPickerMode] = useState('adaptive');
const [saved, setSaved] = useState('No selection submitted');
return <><QueryContainer name="picker-example"><Section title="Adaptive and native selection" note="Adaptive uses the native system picker when the primary pointer is coarse. Desktop keeps the themed popup.">
<Choice label="Picker behavior" value={pickerMode} onValueChange={setPickerMode} options={[{value:'adaptive',label:'Native on mobile'},{value:'custom',label:'Always themed popup'},{value:'native',label:'Always native picker'}]}/>
<form onSubmit={event=>{event.preventDefault();setSaved(String(new FormData(event.currentTarget).get('destination')))}}>
<Stack gap="3"><FieldLayout label="Choose a destination" description="Your value is retained when picker behavior changes.">{attrs=><SelectField {...attrs} items={cities.map(value=>({value,label:value}))} name="destination" defaultValue="Oslo" nativeOnMobile={pickerMode==='adaptive'} native={pickerMode==='native'}/>}</FieldLayout>
<Grid columns={2} gap="3" className="picker-form-actions"><Button type="submit" aria-label="Confirm destination">Confirm</Button><Button type="reset" aria-label="Reset destination">Reset</Button></Grid>
<Result>{saved}</Result>
</Stack>
</form>
<Grid columns={3} gap="4" className="picker-variant-grid" aria-label="Native and themed variants">
{(['outline','filled','plain'] as const).map(variant=><Grid key={variant} gap="3" className="picker-variant-pair" data-picker-variant={variant}>
<FieldLayout label={`Native ${variant}`}>{attrs=><NativeSelect {...attrs} aria-label={`Native ${variant}`} variant={variant} defaultValue="Oslo">{cities.map(value=><option key={value}>{value}</option>)}</NativeSelect>}</FieldLayout>
<FieldLayout label={`Themed ${variant}`}>{attrs=><SelectField {...attrs} aria-label={`Themed ${variant}`} items={cities.map(value=>({value,label:value}))} variant={variant} defaultValue="Oslo"/>}</FieldLayout>
</Grid>)}
</Grid>
</Section></QueryContainer><Section title="Select"><label id={`${id}-select-label`}>Access level</label><Select.Root defaultValue="member" items={{ member: 'Member', viewer: 'Viewer', owner: 'Owner' }}><Select.Trigger aria-labelledby={`${id}-select-label`}><Select.Value /><Select.Icon/></Select.Trigger><Select.Portal><Select.Positioner><Select.Popup><Select.List><Select.Group><Select.GroupLabel>Access roles</Select.GroupLabel>{['member', 'viewer', 'owner'].map(value => <Select.Item key={value} value={value}><Select.ItemText>{value[0]?.toUpperCase()}{value.slice(1)}</Select.ItemText><Select.ItemIndicator/></Select.Item>)}</Select.Group></Select.List></Select.Popup></Select.Positioner></Select.Portal></Select.Root></Section><Section title="Searchable selection"><Combobox.Root items={cities} value={city} onValueChange={setCity}><label htmlFor={`${id}-city`}>Office city</label><Combobox.InputGroup><Combobox.Input id={`${id}-city`} placeholder="Find a city"/><Combobox.Clear aria-label="Clear city"/><Combobox.Trigger aria-label="Show cities"><Combobox.Icon/></Combobox.Trigger></Combobox.InputGroup><Combobox.Portal><Combobox.Positioner><Combobox.Popup><Combobox.Empty>No matching cities</Combobox.Empty><Combobox.List>{(item: string) => <Combobox.Item key={item} value={item}>{item}<Combobox.ItemIndicator/></Combobox.Item>}</Combobox.List></Combobox.Popup></Combobox.Positioner></Combobox.Portal></Combobox.Root><Result>City: {city ?? 'none'}</Result></Section><Section title="Free-form autocomplete"><label htmlFor={`${id}-autocomplete`}>Destination</label><Autocomplete.Root items={cities} value={query} onValueChange={setQuery}><Autocomplete.Input id={`${id}-autocomplete`} placeholder="Choose a suggestion or enter another city"/><Autocomplete.Portal><Autocomplete.Positioner><Autocomplete.Popup><Autocomplete.Empty>No suggestions; you can keep your text.</Autocomplete.Empty><Autocomplete.List>{(item: string) => <Autocomplete.Item key={item} value={item}>{item}</Autocomplete.Item>}</Autocomplete.List></Autocomplete.Popup></Autocomplete.Positioner></Autocomplete.Portal><span>Current suggestion: <Autocomplete.Value/></span></Autocomplete.Root><Result>Destination: {query || 'empty'}</Result></Section><Section title="Autocomplete with dropdown actions"><label htmlFor={`${id}-actions`}>Destination with actions</label><Autocomplete.Root items={cities} defaultValue="Oslo"><Autocomplete.InputGroup><Autocomplete.Input id={`${id}-actions`}/><Autocomplete.Clear aria-label="Clear destination"/><Autocomplete.Trigger aria-label="Show destinations"><Autocomplete.Icon/></Autocomplete.Trigger></Autocomplete.InputGroup><Autocomplete.Portal><Autocomplete.Positioner><Autocomplete.Popup><Autocomplete.Empty>No suggestions</Autocomplete.Empty><Autocomplete.List>{(item: string) => <Autocomplete.Item key={item} value={item}>{item}</Autocomplete.Item>}</Autocomplete.List></Autocomplete.Popup></Autocomplete.Positioner></Autocomplete.Portal></Autocomplete.Root></Section><Section title="Multiple offices with removable chips"><Combobox.Root multiple items={cities} defaultValue={['Oslo']}><Combobox.Label render={<label htmlFor={`${id}-multiple`}/>}>Multiple offices</Combobox.Label><Combobox.Chips><Combobox.Value>{(values: string[]) => values.map(value => <Combobox.Chip key={value}>{value}<Combobox.ChipRemove aria-label={`Remove ${value}`}/></Combobox.Chip>)}</Combobox.Value><Combobox.Input id={`${id}-multiple`} placeholder="Add an office"/></Combobox.Chips><Combobox.Portal><Combobox.Positioner><Combobox.Popup><Combobox.List><Combobox.Collection>{(item: string) => <Combobox.Item key={item} value={item}>{item}<Combobox.ItemIndicator/></Combobox.Item>}</Combobox.Collection></Combobox.List><Combobox.Empty>No matching offices</Combobox.Empty></Combobox.Popup></Combobox.Positioner></Combobox.Portal></Combobox.Root></Section><GroupedPickers/></>;
}
const destinationGroups = [{label:'Northern Europe',items:['Copenhagen','Oslo','Stockholm']},{label:'City breaks',items:['Amsterdam','Berlin','Lisbon']}];
function GroupedPickers() {
const id=useId();
return <>
<Section title="Grouped destinations">
<label htmlFor={`${id}-grouped-city`}>Choose a grouped destination</label>
<Combobox.Root items={destinationGroups}>
<Combobox.InputGroup><Combobox.Input id={`${id}-grouped-city`} placeholder="Search destinations"/><Combobox.Trigger aria-label="Show grouped destinations"/></Combobox.InputGroup>
<Combobox.Portal><Combobox.Positioner><Combobox.Popup><Combobox.List>{(group:typeof destinationGroups[number])=><Combobox.Group key={group.label} items={group.items}><Combobox.GroupLabel>{group.label}</Combobox.GroupLabel><Combobox.Collection>{(item:string)=><Combobox.Item key={item} value={item}>{item}<Combobox.ItemIndicator/></Combobox.Item>}</Combobox.Collection></Combobox.Group>}</Combobox.List><Combobox.Empty>No matching destinations.</Combobox.Empty></Combobox.Popup></Combobox.Positioner></Combobox.Portal>
</Combobox.Root>
<label htmlFor={`${id}-grouped-suggestion`}>Suggest a grouped destination</label>
<Autocomplete.Root items={destinationGroups}>
<Autocomplete.Input id={`${id}-grouped-suggestion`} placeholder="Type to see grouped suggestions"/>
<Autocomplete.Portal><Autocomplete.Positioner><Autocomplete.Popup><Autocomplete.List>{(group:typeof destinationGroups[number])=><Autocomplete.Group key={group.label} items={group.items}><Autocomplete.GroupLabel>{group.label}</Autocomplete.GroupLabel><Autocomplete.Collection>{(item:string)=><Autocomplete.Item key={item} value={item}>{item}</Autocomplete.Item>}</Autocomplete.Collection></Autocomplete.Group>}</Autocomplete.List><Autocomplete.Empty>No suggestions; your text is retained.</Autocomplete.Empty></Autocomplete.Popup></Autocomplete.Positioner></Autocomplete.Portal>
</Autocomplete.Root>
</Section>
<Section title="Scrollable selection">
<Select.Root defaultValue="Destination 1" items={Array.from({length:30},(_,i)=>({value:`Destination ${i+1}`,label:`Destination ${i+1}`}))}>
<Select.Trigger aria-label="Destination from a long list"><Select.Value/><Select.Icon/></Select.Trigger>
<Select.Portal><Select.Positioner><Select.Popup><Select.ScrollUpArrow/><Select.List>{Array.from({length:30},(_,i)=><Select.Item key={i} value={`Destination ${i+1}`}><Select.ItemText>Destination {i+1}</Select.ItemText><Select.ItemIndicator/></Select.Item>)}</Select.List><Select.ScrollDownArrow/></Select.Popup></Select.Positioner></Select.Portal>
</Select.Root>
</Section>
</>;
}