Combobox Field
Command-style search field that commits an option's id while displaying its label, integrated with TanStack Form via React context. Supports single or multiple selection, static options or async search.
npx shadcn@latest add https://shuip.plvo.dev/r/tsf-combobox-field.json
pnpm dlx shadcn@latest add https://shuip.plvo.dev/r/tsf-combobox-field.json
bun x shadcn@latest add https://shuip.plvo.dev/r/tsf-combobox-field.json
'use client';import { Command as CommandPrimitive } from 'cmdk';import { Check, Loader2, Search, X } from 'lucide-react';import * as React from 'react';import { Badge } from '@/components/ui/badge';import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command';import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field';import { useFieldContext } from '@/components/ui/shuip/tanstack-form/form-context';import { cn } from '@/lib/utils';const DEBOUNCE_TIME = 300;export interface ComboboxOption {value: string;label: string;sublabel?: string;}type ComboboxFieldVariant = 'boxed' | 'ghost';type ComboboxFieldSize = 'sm' | 'default';export interface ComboboxFieldProps {multiple?: boolean;options?: ComboboxOption[];onSearch?: (query: string) => Promise<ComboboxOption[]>;maxResults?: number;variant?: ComboboxFieldVariant;size?: ComboboxFieldSize;label?: string;description?: string;placeholder?: string;emptyText?: string;debounceMs?: number;defaultSelected?: ComboboxOption | ComboboxOption[];}const shellVariants: Record<ComboboxFieldVariant, string> = {boxed:'rounded-md border border-input bg-transparent shadow-xs focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px] aria-invalid:border-destructive aria-invalid:ring-destructive/20',ghost: 'rounded-md border border-transparent bg-transparent',};const shellSizes: Record<ComboboxFieldSize, string> = {default: 'min-h-9 gap-1.5 px-2 py-1 text-sm',sm: 'min-h-7 gap-1 px-1.5 py-0.5 text-xs',};function toArray(value: string | string[] | undefined | null): string[] {if (Array.isArray(value)) return value;if (value) return [value];return [];}export function ComboboxField({multiple = false,options,onSearch,maxResults,variant = 'boxed',size = 'default',label,description,placeholder,emptyText = 'No results',debounceMs = DEBOUNCE_TIME,defaultSelected,}: ComboboxFieldProps) {// The consumer's field type (string vs string[]) decides single vs multi; the context is// widened to the shared shape and values are normalised through `toArray`.const field = useFieldContext<string | string[]>();const { isValid, errors } = field.state.meta;const [open, setOpen] = React.useState(false);const [query, setQuery] = React.useState('');const [typed, setTyped] = React.useState(false);const [results, setResults] = React.useState<ComboboxOption[]>([]);const [isPending, startTransition] = React.useTransition();const debounceTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);const requestIdRef = React.useRef(0);const containerRef = React.useRef<HTMLDivElement>(null);const inputRef = React.useRef<HTMLInputElement>(null);// Static options + presets, resolved during render so a value shows its label on first// paint; async results and manual picks live in `cacheRef`. `getOption` reads both.const cacheRef = React.useRef<Map<string, ComboboxOption> | null>(null);cacheRef.current ??= new Map();const staticLookup = React.useMemo(() => {const map = new Map<string, ComboboxOption>();const seed = Array.isArray(defaultSelected) ? defaultSelected : defaultSelected ? [defaultSelected] : [];for (const option of [...seed, ...(options ?? [])]) map.set(option.value, option);return map;}, [defaultSelected, options]);const getOption = React.useCallback((value: string | undefined) => (value ? (staticLookup.get(value) ?? cacheRef.current?.get(value)) : undefined),[staticLookup],);const selectedValues = toArray(field.state.value);// A pre-filled-but-untouched single label counts as an empty query, so the full list// (or `onSearch('')` recents) shows on focus instead of filtering down to the label.const effectiveQuery = typed ? query : '';// Held in a ref so an inline `onSearch` prop doesn't re-trigger the search effect every render.const onSearchRef = React.useRef(onSearch);onSearchRef.current = onSearch;const hasSearch = Boolean(onSearch);const runSearch = React.useCallback((search: string) => {const search$ = onSearchRef.current;if (!search$) return;const requestId = ++requestIdRef.current;startTransition(async () => {try {const res = await search$(search);if (requestId !== requestIdRef.current) return;for (const option of res) cacheRef.current?.set(option.value, option);setResults(res);} catch {if (requestId !== requestIdRef.current) return;setResults([]);}});}, []);React.useEffect(() => {if (!hasSearch || !open) return;if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);if (!effectiveQuery) {runSearch('');return;}debounceTimerRef.current = setTimeout(() => runSearch(effectiveQuery), debounceMs);return () => {if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);};}, [effectiveQuery, hasSearch, open, debounceMs, runSearch]);const items = React.useMemo(() => {let list: ComboboxOption[];if (onSearch) {list = results;} else if (!options) {list = [];} else if (!effectiveQuery) {list = options;} else {list = options.filter((option) => option.label.toLowerCase().includes(effectiveQuery.toLowerCase()));}return maxResults ? list.slice(0, maxResults) : list;}, [onSearch, results, options, effectiveQuery, maxResults]);const commit = (next: string[]) => {field.handleChange(multiple ? next : (next[0] ?? ''));};const firstValue = selectedValues[0];const singleLabel = !multiple ? getOption(firstValue)?.label : undefined;const inputValue = open ? query : multiple ? '' : (singleLabel ?? firstValue ?? '');const closeMenu = () => {setOpen(false);setQuery('');setTyped(false);};const handleFocus = () => {setOpen(true);setTyped(false);if (!multiple && singleLabel) {setQuery(singleLabel);requestAnimationFrame(() => inputRef.current?.select());} else {setQuery('');}};const handleBlur = (e: React.FocusEvent) => {if (containerRef.current?.contains(e.relatedTarget as Node | null)) return;field.handleBlur();closeMenu();};const handleSelect = (option: ComboboxOption) => {cacheRef.current?.set(option.value, option);if (multiple) {const next = selectedValues.includes(option.value)? selectedValues.filter((value) => value !== option.value): [...selectedValues, option.value];commit(next);setQuery('');setTyped(false);inputRef.current?.focus();return;}commit([option.value]);closeMenu();inputRef.current?.blur();};const removeValue = (value: string) => {commit(selectedValues.filter((current) => current !== value));};const handleValueChange = (value: string) => {setQuery(value);setTyped(true);setOpen(true);};const handleKeyDown = (e: React.KeyboardEvent) => {if (e.key === 'Escape') {e.preventDefault();closeMenu();inputRef.current?.blur();return;}if (multiple && e.key === 'Backspace' && query === '' && selectedValues.length > 0) {removeValue(selectedValues[selectedValues.length - 1]);}};const iconSize = size === 'sm' ? 'size-3.5' : 'size-4';const showPlaceholder = multiple ? selectedValues.length === 0 : true;return (<Field className='gap-2' data-invalid={!isValid}>{label && <FieldLabel>{label}</FieldLabel>}<Command shouldFilter={false} className='relative h-auto overflow-visible bg-transparent'><divref={containerRef}aria-invalid={!isValid || undefined}className={cn('flex w-full flex-wrap items-center', shellVariants[variant], shellSizes[size])}onMouseDown={(e) => {if (e.target !== inputRef.current) {e.preventDefault();inputRef.current?.focus();}}}><Search className={cn('shrink-0 text-muted-foreground', iconSize)} aria-hidden />{multiple &&selectedValues.map((value) => (<Badge key={value} variant='secondary' className='gap-1'>{getOption(value)?.label ?? value}<buttontype='button'className='cursor-pointer rounded-full outline-none focus-visible:ring-1 focus-visible:ring-ring'onMouseDown={(e) => {e.preventDefault();removeValue(value);}}aria-label={`Remove ${getOption(value)?.label ?? value}`}><X className='size-3' /></button></Badge>))}<CommandPrimitive.Inputref={inputRef}value={inputValue}placeholder={showPlaceholder ? placeholder : undefined}aria-label={label}aria-invalid={!isValid || undefined}onValueChange={handleValueChange}onFocus={handleFocus}onBlur={handleBlur}onKeyDown={handleKeyDown}className='min-w-20 flex-1 bg-transparent outline-hidden placeholder:text-muted-foreground'/>{isPending && <Loader2 className={cn('shrink-0 animate-spin text-muted-foreground', iconSize)} />}</div>{open && (<divclassName='absolute top-full left-0 z-50 mt-1 w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md'onMouseDown={(e) => e.preventDefault()}><CommandList className='max-h-60'>{items.length === 0 ? (<div className='py-6 text-center text-sm text-muted-foreground'>{isPending ? 'Searching…' : emptyText}</div>) : (<CommandGroup>{items.map((option) => {const isSelected = selectedValues.includes(option.value);return (<CommandItemkey={option.value}value={option.value}onSelect={() => handleSelect(option)}className='cursor-pointer'><Check className={isSelected ? 'opacity-100' : 'opacity-0'} /><div className='flex flex-col'><span>{option.label}</span>{option.sublabel && <span className='text-xs text-muted-foreground'>{option.sublabel}</span>}</div></CommandItem>);})}</CommandGroup>)}</CommandList></div>)}</Command>{!isValid && (<FieldErrorclassName='text-xs text-left'errors={errors.map((error) => ({ message: typeof error === 'string' ? error : error?.message }))}/>)}{description && <FieldDescription className='text-xs'>{description}</FieldDescription>}</Field>);}
ComboboxField is a searchable select: the user types to filter, navigates results with the keyboard, and picks one (or several) entries. The committed form value is the option's value (a stable id) while the label is shown for display — distinct from autocomplete-field, where the committed value is the typed string.
It reads the surrounding field via useFieldContext, so you compose it inside a <form.AppField> rather than passing a form instance by prop. The field value type you declare drives single (string) vs multiple (string[]); a preset id renders its label immediately via defaultSelected.
Built-in features
- Id / label split: commits
value, displayslabel— map your own rows toComboboxOption[]. - Single or multiple: toggle with
multiple; multi renders chips and toggles selection with a check. - Two search modes: static
options(client-side substring filter onlabel) or asynconSearch(debounced fetch).onSearchwins when both are passed. - Recents on open:
onSearch('')fires when the menu opens empty, so you can return a suggested list. - Keyboard navigation: ArrowUp/ArrowDown to move, Enter to select, Escape to close, Backspace to remove the last chip (multi).
- Variants:
variant(boxedor borderlessghost) andsize(smordefault).
Setup
Field components are bound via React context. In your project, create lib/form.ts once:
// lib/form.ts
import { createFormHook } from '@tanstack/react-form';
import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';
import { ComboboxField } from '@/components/ui/shuip/tanstack-form/combobox-field';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';
export const { useAppForm } = createFormHook({
fieldContext,
formContext,
fieldComponents: { ComboboxField },
formComponents: { SubmitButton },
});See the form-context item for details.
Examples
Async
'use client';import { createFormHook } from '@tanstack/react-form';import { ComboboxField, type ComboboxOption } from '@/components/ui/shuip/tanstack-form/combobox-field';import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';const USERS: ComboboxOption[] = [{ value: 'u1', label: 'Ada Lovelace', sublabel: 'ada@example.com' },{ value: 'u2', label: 'Alan Turing', sublabel: 'alan@example.com' },{ value: 'u3', label: 'Grace Hopper', sublabel: 'grace@example.com' },{ value: 'u4', label: 'Katherine Johnson', sublabel: 'katherine@example.com' },{ value: 'u5', label: 'Margaret Hamilton', sublabel: 'margaret@example.com' },{ value: 'u6', label: 'Linus Torvalds', sublabel: 'linus@example.com' },];const RECENT_IDS = ['u3', 'u1'];async function searchUsers(query: string): Promise<ComboboxOption[]> {await new Promise((resolve) => setTimeout(resolve, 400));if (!query) return USERS.filter((user) => RECENT_IDS.includes(user.value));return USERS.filter((user) => user.label.toLowerCase().includes(query.toLowerCase()));}const { useAppForm } = createFormHook({fieldContext,formContext,fieldComponents: { ComboboxField },formComponents: { SubmitButton },});export default function TsfComboboxFieldAsyncExample() {const form = useAppForm({defaultValues: {assignee: '',},onSubmit: async ({ value }) => {alert(JSON.stringify(value, null, 2));},});return (<formonSubmit={(e) => {e.preventDefault();form.handleSubmit();}}className='space-y-4'><form.AppFieldname='assignee'validators={{onChange: ({ value }) => (value.length === 0 ? 'Assignee is required' : undefined),}}children={(field) => (<field.ComboboxFieldlabel='Assignee'description='Empty query returns recents; typing searches'placeholder='Search a user…'onSearch={searchUsers}maxResults={5}/>)}/><form.AppForm><form.SubmitButton>Submit</form.SubmitButton></form.AppForm></form>);}
Default
'use client';import { createFormHook } from '@tanstack/react-form';import { ComboboxField, type ComboboxOption } from '@/components/ui/shuip/tanstack-form/combobox-field';import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';const COUNTRIES: ComboboxOption[] = [{ value: 'fr', label: 'France' },{ value: 'de', label: 'Germany' },{ value: 'es', label: 'Spain' },{ value: 'it', label: 'Italy' },{ value: 'pt', label: 'Portugal' },{ value: 'nl', label: 'Netherlands' },{ value: 'be', label: 'Belgium' },];const { useAppForm } = createFormHook({fieldContext,formContext,fieldComponents: { ComboboxField },formComponents: { SubmitButton },});export default function TsfComboboxFieldExample() {const form = useAppForm({defaultValues: {country: 'fr',},onSubmit: async ({ value }) => {await new Promise((resolve) => setTimeout(resolve, 500));alert(JSON.stringify(value, null, 2));},});return (<formonSubmit={(e) => {e.preventDefault();form.handleSubmit();}}className='space-y-4'><form.AppFieldname='country'validators={{onChange: ({ value }) => (value.length === 0 ? 'Country is required' : undefined),}}children={(field) => (<field.ComboboxFieldlabel='Country'description='The preset value resolves its label at rest'placeholder='Search a country…'options={COUNTRIES}defaultSelected={{ value: 'fr', label: 'France' }}/>)}/><form.AppForm><form.SubmitButton>Submit</form.SubmitButton></form.AppForm></form>);}
Multiple
'use client';import { createFormHook } from '@tanstack/react-form';import { ComboboxField, type ComboboxOption } from '@/components/ui/shuip/tanstack-form/combobox-field';import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';const FRAMEWORKS: ComboboxOption[] = [{ value: 'next', label: 'Next.js', sublabel: 'React framework' },{ value: 'remix', label: 'Remix', sublabel: 'React framework' },{ value: 'astro', label: 'Astro', sublabel: 'Content-driven' },{ value: 'svelte', label: 'SvelteKit', sublabel: 'Svelte framework' },{ value: 'nuxt', label: 'Nuxt', sublabel: 'Vue framework' },{ value: 'solid', label: 'SolidStart', sublabel: 'Solid framework' },];const { useAppForm } = createFormHook({fieldContext,formContext,fieldComponents: { ComboboxField },formComponents: { SubmitButton },});export default function TsfComboboxFieldMultipleExample() {const form = useAppForm({defaultValues: {frameworks: ['next'] as string[],},onSubmit: async ({ value }) => {alert(JSON.stringify(value, null, 2));},});return (<formonSubmit={(e) => {e.preventDefault();form.handleSubmit();}}className='space-y-4'><form.AppFieldname='frameworks'validators={{onChange: ({ value }) => (value.length === 0 ? 'Pick at least one framework' : undefined),}}children={(field) => (<field.ComboboxFieldmultiplelabel='Frameworks'description='Pick several; backspace removes the last chip'placeholder='Search frameworks…'options={FRAMEWORKS}defaultSelected={[{ value: 'next', label: 'Next.js', sublabel: 'React framework' }]}/>)}/><form.AppForm><form.SubmitButton>Submit</form.SubmitButton></form.AppForm></form>);}
Sizes
'use client';import { createFormHook } from '@tanstack/react-form';import { ComboboxField, type ComboboxOption } from '@/components/ui/shuip/tanstack-form/combobox-field';import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';const COUNTRIES: ComboboxOption[] = [{ value: 'fr', label: 'France' },{ value: 'de', label: 'Germany' },{ value: 'es', label: 'Spain' },{ value: 'it', label: 'Italy' },{ value: 'pt', label: 'Portugal' },];const PRESET: ComboboxOption = { value: 'fr', label: 'France' };const { useAppForm } = createFormHook({fieldContext,formContext,fieldComponents: { ComboboxField },formComponents: {},});export default function TsfComboboxFieldSizesExample() {const form = useAppForm({defaultValues: {small: 'fr',normal: 'fr',},});return (<form className='space-y-4'><form.AppFieldname='small'children={(field) => (<field.ComboboxFieldsize='sm'label='Small'placeholder='Search a country…'options={COUNTRIES}defaultSelected={PRESET}/>)}/><form.AppFieldname='normal'children={(field) => (<field.ComboboxFieldsize='default'label='Default'placeholder='Search a country…'options={COUNTRIES}defaultSelected={PRESET}/>)}/></form>);}
Variants
'use client';import { createFormHook } from '@tanstack/react-form';import { ComboboxField, type ComboboxOption } from '@/components/ui/shuip/tanstack-form/combobox-field';import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';const FRAMEWORKS: ComboboxOption[] = [{ value: 'next', label: 'Next.js' },{ value: 'remix', label: 'Remix' },{ value: 'astro', label: 'Astro' },{ value: 'svelte', label: 'SvelteKit' },{ value: 'nuxt', label: 'Nuxt' },{ value: 'solid', label: 'SolidStart' },];const { useAppForm } = createFormHook({fieldContext,formContext,fieldComponents: { ComboboxField },formComponents: {},});export default function TsfComboboxFieldVariantsExample() {const form = useAppForm({defaultValues: {boxed: ['next'] as string[],ghost: ['next', 'remix'] as string[],},});return (<form className='space-y-4'><form.AppFieldname='boxed'children={(field) => (<field.ComboboxFieldmultiplevariant='boxed'label='Boxed (default)'placeholder='Search frameworks…'options={FRAMEWORKS}defaultSelected={[{ value: 'next', label: 'Next.js' }]}/>)}/><form.AppFieldname='ghost'children={(field) => (<field.ComboboxFieldmultiplevariant='ghost'label='Ghost (borderless — chips only)'placeholder='Search frameworks…'options={FRAMEWORKS}defaultSelected={[{ value: 'next', label: 'Next.js' },{ value: 'remix', label: 'Remix' },]}/>)}/></form>);}
Props
Prop
Type