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.

Loading...

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 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: 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

Loading...
'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

Loading...
'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

On this page