Autocomplete Field
Free-text input that suggests matching values from a static list or an async search, integrated with TanStack Form via React context. The committed value is a plain string.
npx shadcn@latest add @shuip/tsf-autocomplete-field
pnpm dlx shadcn@latest add @shuip/tsf-autocomplete-field
bun x shadcn@latest add @shuip/tsf-autocomplete-field
'use client';
// Override of component.radix.tsx for what the codemod cannot derive: Tab closes the list, a click on the input
// reopens it, an outside press on the anchor does not dismiss it, and the blur handler does not look at the popup.
import { Popover as PopoverPrimitive } from '@base-ui/react/popover';
import { Loader2 } from 'lucide-react';
import * as React from 'react';
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Popover } from '@/components/ui/popover';
import {
listboxClassName,
SuggestionEmpty,
SuggestionOption,
useSuggestionList,
} from '@/components/ui/shuip/suggestion-list';
import { useFieldContext } from '@/components/ui/shuip/tanstack-form/form-context';
import { cn } from '@/lib/utils';
const DEBOUNCE_TIME = 300;
function defaultFilter(suggestion: string, query: string) {
return suggestion.toLowerCase().includes(query.toLowerCase());
}
export interface AutocompleteFieldProps {
label?: string;
description?: string;
placeholder?: string;
suggestions?: string[];
onSearch?: (query: string) => Promise<string[]>;
emptyText?: string;
debounceMs?: number;
filter?: (suggestion: string, query: string) => boolean;
fieldProps?: React.ComponentProps<typeof Field>;
props?: Omit<React.ComponentProps<'input'>, 'value' | 'onChange'>;
}
export function AutocompleteField({
label,
description,
placeholder,
suggestions,
onSearch,
emptyText = 'No results',
debounceMs = DEBOUNCE_TIME,
filter = defaultFilter,
fieldProps,
props,
}: AutocompleteFieldProps) {
const field = useFieldContext<string>();
const { isValid, errors } = field.state.meta;
const [open, setOpen] = React.useState(false);
const [selectedIndex, setSelectedIndex] = React.useState(-1);
const [results, setResults] = React.useState<string[]>([]);
const [isPending, startTransition] = React.useTransition();
const debounceTimerRef = React.useRef<NodeJS.Timeout | null>(null);
const requestIdRef = React.useRef(0);
const anchorRef = React.useRef<HTMLDivElement>(null);
const query = field.state.value ?? '';
const items = React.useMemo(() => {
if (onSearch) return results;
if (!suggestions) return [];
if (!query) return suggestions;
return suggestions.filter((suggestion) => filter(suggestion, query));
}, [onSearch, results, suggestions, query, filter]);
React.useEffect(() => {
if (!onSearch || !open) return;
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
if (!query) {
requestIdRef.current++;
setResults([]);
return;
}
debounceTimerRef.current = setTimeout(() => {
const requestId = ++requestIdRef.current;
startTransition(async () => {
try {
const res = await onSearch(query);
if (requestId !== requestIdRef.current) return;
setResults(res);
} catch {
if (requestId !== requestIdRef.current) return;
setResults([]);
}
});
}, debounceMs);
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
requestIdRef.current++;
};
}, [query, onSearch, open, debounceMs]);
const handleSelect = (value: string) => {
field.handleChange(value);
setOpen(false);
setSelectedIndex(-1);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!open) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setOpen(true);
}
return;
}
if (e.key === 'Escape') {
e.preventDefault();
setOpen(false);
setSelectedIndex(-1);
return;
}
if (e.key === 'Tab') {
setOpen(false);
setSelectedIndex(-1);
return;
}
if (items.length === 0) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0));
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1));
break;
case 'Enter':
if (selectedIndex >= 0 && selectedIndex < items.length) {
e.preventDefault();
handleSelect(items[selectedIndex]);
}
break;
}
};
const handleBlur = () => {
field.handleBlur();
setTimeout(() => {
setOpen(false);
setSelectedIndex(-1);
}, 150);
};
const id = props?.id ?? field.name;
const suggestionList = useSuggestionList({
inputId: id,
label,
ariaLabel: props?.['aria-label'],
placeholder,
open,
count: items.length,
selectedIndex,
busy: isPending,
});
return (
<Field className='gap-2' data-invalid={!isValid} {...fieldProps}>
{label && (
<FieldLabel id={suggestionList.labelId} htmlFor={id}>
{label}
</FieldLabel>
)}
<Popover
open={open}
onOpenChange={(nextOpen, { reason, event }) => {
if (reason === 'outside-press' && anchorRef.current?.contains(event.target as Node)) return;
setOpen(nextOpen);
}}
>
<div ref={anchorRef} className='relative'>
<Input
name={field.name}
value={query}
placeholder={placeholder}
autoComplete='off'
{...props}
id={id}
onChange={(e) => {
field.handleChange(e.target.value);
setOpen(true);
setSelectedIndex(-1);
}}
onFocus={() => setOpen(true)}
onClick={() => setOpen(true)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
{...suggestionList.inputProps}
aria-invalid={!isValid}
/>
{isPending && (
<div className='absolute inset-y-0 right-0 flex items-center pr-3'>
<Loader2 className='size-4 animate-spin text-muted-foreground' />
</div>
)}
</div>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner anchor={anchorRef} align='start' sideOffset={4} className='isolate z-50'>
<PopoverPrimitive.Popup
data-slot='popover-content'
{...suggestionList.listboxProps}
initialFocus={false}
className={cn(
'z-50 flex w-72 origin-(--transform-origin) flex-col gap-4 rounded-md bg-popover p-4 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
listboxClassName,
)}
style={{ width: 'var(--anchor-width)' }}
>
{items.length === 0 && <SuggestionEmpty>{isPending ? 'Searching…' : emptyText}</SuggestionEmpty>}
{items.map((item, index) => (
<SuggestionOption
key={`${item}-${index}`}
id={suggestionList.optionId(index)}
highlighted={selectedIndex === index}
onSelect={() => handleSelect(item)}
onHighlight={() => setSelectedIndex(index)}
>
{item}
</SuggestionOption>
))}
</PopoverPrimitive.Popup>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
</Popover>
{!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>
);
}
The command is the same for Base UI and Radix: the CLI reads the style in your components.json and installs the matching variant.
AutocompleteField is a text input that proposes matching strings as the user types, while still allowing any free-text value. It reads the surrounding field via useFieldContext, so you compose it inside a <form.AppField> rather than passing a form instance by prop. It stores a plain string.
Built-in features
- Two suggestion modes: a static
suggestionsarray (filtered client-side) or an asynconSearchfunction (debounced fetch). - Free text: the typed value is always kept — closing the dropdown without selecting commits what was typed.
- Keyboard navigation: ArrowDown opens the list, ArrowUp/ArrowDown to move, Enter to select, Escape to close.
- Combobox semantics: the input is a combobox that controls a listbox of options, so screen readers announce the suggestions and the highlighted option.
- Custom matching: override the default case-insensitive substring match via
filter(static mode).
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 { AutocompleteField } from '@/components/ui/shuip/tanstack-form/autocomplete-field';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';
export const { useAppForm } = createFormHook({
fieldContext,
formContext,
fieldComponents: { AutocompleteField },
formComponents: { SubmitButton },
});See the form-context item for details.
Examples
Async
'use client';
import { createFormHook } from '@tanstack/react-form';
import { AutocompleteField } from '@/components/ui/shuip/tanstack-form/autocomplete-field';
import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';
const FRUITS = ['Apple', 'Apricot', 'Banana', 'Blackberry', 'Blueberry', 'Cherry', 'Mango', 'Melon', 'Orange', 'Peach'];
async function searchFruits(query: string): Promise<string[]> {
await new Promise((resolve) => setTimeout(resolve, 400));
return FRUITS.filter((fruit) => fruit.toLowerCase().includes(query.toLowerCase()));
}
const { useAppForm } = createFormHook({
fieldContext,
formContext,
fieldComponents: { AutocompleteField },
formComponents: { SubmitButton },
});
export default function TsfAutocompleteFieldAsyncExample() {
const form = useAppForm({
defaultValues: {
fruit: '',
},
onSubmit: async ({ value }) => {
alert(JSON.stringify(value, null, 2));
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className='space-y-4'
>
<form.AppField
name='fruit'
children={(field) => (
<field.AutocompleteField
label='Fruit'
description='Async search with simulated latency'
placeholder='Search a fruit…'
onSearch={searchFruits}
/>
)}
/>
<form.AppForm>
<form.SubmitButton>Submit</form.SubmitButton>
</form.AppForm>
</form>
);
}
Default
'use client';
import { createFormHook } from '@tanstack/react-form';
import { AutocompleteField } from '@/components/ui/shuip/tanstack-form/autocomplete-field';
import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';
const SOURCES = ['LinkedIn', 'IRL', 'Prospection', 'Referral', 'Website', 'Cold call'];
const { useAppForm } = createFormHook({
fieldContext,
formContext,
fieldComponents: { AutocompleteField },
formComponents: { SubmitButton },
});
export default function TsfAutocompleteFieldExample() {
const form = useAppForm({
defaultValues: {
source: '',
},
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='source'
validators={{
onChange: ({ value }) => (value.length === 0 ? 'Source is required' : undefined),
}}
children={(field) => (
<field.AutocompleteField
label='Source'
description='Pick a known source or type your own'
placeholder='e.g. LinkedIn'
suggestions={SOURCES}
/>
)}
/>
<form.AppForm>
<form.SubmitButton>Submit</form.SubmitButton>
</form.AppForm>
</form>
);
}
Props
Prop
Type