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'>
<div
ref={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}
<button
type='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.Input
ref={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 && (
<div
className='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 (
<CommandItem
key={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 && (
<FieldError
className='text-xs text-left'
errors={errors.map((error) => ({ message: typeof error === 'string' ? error : error?.message }))}
/>
)}
{description && <FieldDescription className='text-xs'>{description}</FieldDescription>}
</Field>
);
}
Loading...

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, displays label — map your own rows to ComboboxOption[].
  • Single or multiple: toggle with multiple; multi renders chips and toggles selection with a check.
  • Two search modes: static options (client-side substring filter on label) or async onSearch (debounced fetch). onSearch wins 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 (boxed or borderless ghost) and size (sm or default).

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

Loading...
'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 (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className='space-y-4'
>
<form.AppField
name='assignee'
validators={{
onChange: ({ value }) => (value.length === 0 ? 'Assignee is required' : undefined),
}}
children={(field) => (
<field.ComboboxField
label='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

Loading...
'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 (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className='space-y-4'
>
<form.AppField
name='country'
validators={{
onChange: ({ value }) => (value.length === 0 ? 'Country is required' : undefined),
}}
children={(field) => (
<field.ComboboxField
label='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

Loading...
'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 (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className='space-y-4'
>
<form.AppField
name='frameworks'
validators={{
onChange: ({ value }) => (value.length === 0 ? 'Pick at least one framework' : undefined),
}}
children={(field) => (
<field.ComboboxField
multiple
label='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

Loading...
'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.AppField
name='small'
children={(field) => (
<field.ComboboxField
size='sm'
label='Small'
placeholder='Search a country…'
options={COUNTRIES}
defaultSelected={PRESET}
/>
)}
/>
<form.AppField
name='normal'
children={(field) => (
<field.ComboboxField
size='default'
label='Default'
placeholder='Search a country…'
options={COUNTRIES}
defaultSelected={PRESET}
/>
)}
/>
</form>
);
}

Variants

Loading...
'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.AppField
name='boxed'
children={(field) => (
<field.ComboboxField
multiple
variant='boxed'
label='Boxed (default)'
placeholder='Search frameworks…'
options={FRAMEWORKS}
defaultSelected={[{ value: 'next', label: 'Next.js' }]}
/>
)}
/>
<form.AppField
name='ghost'
children={(field) => (
<field.ComboboxField
multiple
variant='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

On this page