Autocomplete Field

Free-text input that suggests matching values from a static list or an async search. The committed value is a plain string — suggestions only propose, they never restrict.

npx shadcn@latest add https://shuip.plvo.dev/r/rhf-autocomplete-field.json
pnpm dlx shadcn@latest add https://shuip.plvo.dev/r/rhf-autocomplete-field.json
bun x shadcn@latest add https://shuip.plvo.dev/r/rhf-autocomplete-field.json
'use client';
import type { Lens } from '@hookform/lenses';
import { Loader2 } from 'lucide-react';
import * as React from 'react';
import { useController } from 'react-hook-form';
import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from '@/components/ui/command';
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
const DEBOUNCE_TIME = 300;
function defaultFilter(suggestion: string, query: string) {
return suggestion.toLowerCase().includes(query.toLowerCase());
}
export interface AutocompleteFieldProps extends Omit<React.ComponentProps<typeof Input>, 'value' | 'onChange'> {
lens: Lens<string>;
label?: string;
description?: string;
placeholder?: string;
suggestions?: string[];
onSearch?: (query: string) => Promise<string[]>;
emptyText?: string;
debounceMs?: number;
filter?: (suggestion: string, query: string) => boolean;
}
export function AutocompleteField({
lens,
label,
description,
placeholder,
suggestions,
onSearch,
emptyText = 'No results',
debounceMs = DEBOUNCE_TIME,
filter = defaultFilter,
...props
}: AutocompleteFieldProps) {
const { field, fieldState } = useController(lens.interop());
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 popoverRef = React.useRef<HTMLDivElement>(null);
const query = field.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.onChange(value);
setOpen(false);
setSelectedIndex(-1);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!open) return;
if (e.key === 'Escape') {
e.preventDefault();
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 = (e: React.FocusEvent) => {
const relatedTarget = e.relatedTarget as HTMLElement;
if (popoverRef.current?.contains(relatedTarget)) {
return;
}
field.onBlur();
setTimeout(() => {
setOpen(false);
setSelectedIndex(-1);
}, 150);
};
const id = props.id ?? field.name;
return (
<Field className='gap-2' data-invalid={fieldState.invalid}>
{label && <FieldLabel htmlFor={id}>{label}</FieldLabel>}
<Popover open={open} onOpenChange={setOpen}>
<PopoverAnchor asChild>
<div className='relative'>
<Input
name={field.name}
value={query}
placeholder={placeholder}
autoComplete='off'
{...props}
id={id}
onChange={(e) => {
field.onChange(e.target.value);
setOpen(true);
setSelectedIndex(-1);
}}
onFocus={() => setOpen(true)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
aria-invalid={fieldState.invalid}
/>
{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>
</PopoverAnchor>
<PopoverContent
ref={popoverRef}
className='p-0'
align='start'
onOpenAutoFocus={(e) => e.preventDefault()}
style={{ width: 'var(--radix-popover-trigger-width)' }}
>
<Command shouldFilter={false} className='w-full'>
<CommandList className='max-h-60'>
<CommandEmpty>{isPending ? 'Searching…' : emptyText}</CommandEmpty>
<CommandGroup>
{items.map((item, index) => (
<CommandItem
key={`${item}-${index}`}
value={item}
onSelect={() => handleSelect(item)}
className={cn('cursor-pointer', selectedIndex === index && 'bg-accent')}
>
{item}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{fieldState.invalid && <FieldError className='text-xs text-left' errors={[fieldState.error]} />}
{description && <FieldDescription className='text-xs'>{description}</FieldDescription>}
</Field>
);
}
Loading...

AutocompleteField is a text input that proposes matching strings as the user types, while still allowing any free-text value. It binds to React Hook Form through @hookform/lenses (lens prop) and stores a plain string.

Use it when a field has a set of common values worth suggesting (sources, tags, cities…) but you don't want to force the user to pick from the list.

Built-in features

  • Two suggestion modes: a static suggestions array (filtered client-side) or an async onSearch function (debounced fetch).
  • Free text: the typed value is always kept — closing the dropdown without selecting commits what was typed.
  • Keyboard navigation: ArrowUp/ArrowDown to move, Enter to select, Escape to close.
  • Custom matching: override the default case-insensitive substring match via filter (static mode).

Examples

Async

Loading...
'use client';
import { useLens } from '@hookform/lenses';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Form } from '@/components/ui/form';
import { AutocompleteField } from '@/components/ui/shuip/react-hook-form/autocomplete-field';
import { SubmitButton } from '@/components/ui/shuip/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 zodSchema = z.object({
fruit: z.string(),
});
type Values = z.infer<typeof zodSchema>;
export default function AutocompleteFieldAsyncExample() {
const form = useForm<Values>({ defaultValues: { fruit: '' } });
const lens = useLens({ control: form.control });
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((values) => alert(values.fruit))} className='space-y-4'>
<AutocompleteField
lens={lens.focus('fruit')}
label='Fruit'
description='Async search with simulated latency'
placeholder='Search a fruit…'
onSearch={searchFruits}
/>
<SubmitButton>Submit</SubmitButton>
</form>
</Form>
);
}

Default

Loading...
'use client';
import { useLens } from '@hookform/lenses';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Form } from '@/components/ui/form';
import { AutocompleteField } from '@/components/ui/shuip/react-hook-form/autocomplete-field';
import { SubmitButton } from '@/components/ui/shuip/submit-button';
const SOURCES = ['LinkedIn', 'IRL', 'Prospection', 'Referral', 'Website', 'Cold call'];
const zodSchema = z.object({
source: z.string().nonempty({ message: 'Source is required' }),
});
type Values = z.infer<typeof zodSchema>;
export default function AutocompleteFieldExample() {
const form = useForm<Values>({
defaultValues: { source: '' },
resolver: zodResolver(zodSchema),
});
const lens = useLens({ control: form.control });
function onSubmit(values: Values) {
alert(`Source: ${values.source}`);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<AutocompleteField
lens={lens.focus('source')}
label='Source'
description='Pick a known source or type your own'
placeholder='e.g. LinkedIn'
suggestions={SOURCES}
/>
<SubmitButton>Submit</SubmitButton>
</form>
</Form>
);
}

Props

Prop

Type

On this page