For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to any URL or sending an Accept: text/markdown header. An agent skill is available at /.well-known/agent-skills/site-skill.md.
1
Support

Autocomplete

Typed suggestions for free text, tags, and constrained single or multiple selection.

Playground

Live preview
Code reproduces configuration, not live values or drafts. Enter creates free-text tags; Reset clears configuration, committed values and drafts.
Customize Autocomplete
Customize
autocomplete-demo.tsx
"use client";

import { useState } from "react";
import { Autocomplete } from "@/components/autocomplete";

type Framework = { id: string; label: string; group: string };
const items: readonly Framework[] = [
  {
    "group": "Libraries",
    "id": "react",
    "label": "React"
  },
  {
    "group": "Frameworks",
    "id": "vue",
    "label": "Vue"
  },
  {
    "group": "Frameworks",
    "id": "svelte",
    "label": "Svelte"
  }
];

export function AutocompleteDemo() {
  const [value, setValue] = useState<string>("");
  return (
    <Autocomplete
      items={items}
      getItemLabel={(item) => item.label}
      getItemValue={(item) => item.id}
      value={value}
      onValueChange={setValue}
      mode={"free-text"}
      multiple={false}
      label={"Framework"}
      labelStyle={"static"}
      placeholder={"Search frameworks"}
      size={"default"}
      disabled={false}
      readOnly={false}
      invalid={false}
      clearable={false}
      showTrigger={false}
      animated={true}
      autoHighlight={false}
      loading={false}
      loadingProps={{"variant":"arc","size":16}}
      backgroundLoading={false}
      className={"w-72 max-w-full"}
      inputProps={{ "aria-label": "Framework" }}
    />
  );
}

Installation

Initialize shadcn in your React project, then install this component from the Vandor UI registry:

# From the Vandor UI checkout, after initializing shadcn in the consumer:
pnpm registry:build
node scripts/install-component-family.cjs autocomplete /absolute/path/to/consumer

Dependencies

Installed automatically by the registry CLI. For manual setup:

Dependencies4 required packages
  • @base-ui/react@^1.8.0
  • cn@^0.4.0
  • lucide-react
  • motion@^12.38.0
$ pnpm add @base-ui/react@^1.8.0 cn@^0.4.0 lucide-react motion@^12.38.0

Usage

Labels use a transparent fieldset/legend notch over the existing surface. Floating labels remain raised while focus moves between the input, clear button, trigger, or chips; static labels sit above the field. The shared field-outline.tsx support file installs in your configured UI directory, not inside the Autocomplete family. CLI installation includes it; copying only the Source block does not.

Autocomplete assembles the field, suggestions, optional actions and chips. Strings need no accessors:

"use client";
 
import { Autocomplete } from "@/components/autocomplete";
 
export function Demo() {
  return <Autocomplete items={["React", "Vue", "Svelte"]} label="Framework" />;
}

Use your configured UI alias if it differs. React 19, Tailwind CSS 4 and the standard shadcn theme tokens are required. Runtime dependencies are @base-ui/react@^1.8.0, cn@^0.4.0, lucide-react, and motion@^12.38.0. No website stylesheet or provider is imported. Use your consumer's background, input, foreground, accent, popover, border, ring, destructive and muted-foreground tokens; standard shadcn CSS supplies these. Popup motion is JavaScript-driven and requires no extra animation stylesheet.

Family installation supplies an index.ts UI entry, core modules, compact feedback and pagination under your components alias. It also installs the public Loading component's entire animation catalog and MIT license. Copying Source alone is insufficient. Core does not require TanStack Query.

Stock shadcn 4.5.0 cannot preserve nested families under divergent aliases. From this registry checkout, generate local artifacts and use the alias-aware wrapper:

pnpm registry:build
node scripts/install-component-family.cjs autocomplete /absolute/path/to/consumer
node scripts/install-component-family.cjs autocomplete-query /absolute/path/to/consumer

The optional second command requires your own Query provider. The wrapper uses the real CLI, configured components/UI aliases and locally generated dependency closure. Review overwrite prompts, especially when optional Query installs core as a dependency. It never deletes old ui/ copies. Transfer customizations and remove obsolete copies deliberately. Consumer TypeScript aliases must resolve to one in-project directory; ambiguous fallback paths are rejected, not guessed.

Four value contracts

ModeMultipleCommitted valueEmpty
free-text (default)falsestring""
free-texttruestring[][]
selectionfalseoriginal Itemnull
selectiontrueoriginal Item[][]

Literal mode/multiple settings infer callback types. Narrow runtime unions before rendering. value is committed application state; controlled props stay authoritative. defaultValue initializes uncontrolled state. Readonly item collections are supported and supplied arrays are never mutated.

Free text

Typing updates the string immediately. Selecting a suggestion replaces it. Blur retains arbitrary text. There is no separate inputValue in single free-text mode.

Text: (empty)
autocomplete-demo.tsx
"use client";

import { useState } from "react";

import { Autocomplete } from "@/components/ui/autocomplete";

const frameworks = ["React", "Vue", "Svelte", "Solid"];
export const autocompleteUsers = [
  { id: "ada", name: "Ada Lovelace", team: "Engineering" },
  { id: "bea", name: "Bea Chen", team: "Design" },
  { id: "cam", name: "Cam Rivera", team: "Engineering" },
];
export type AutocompleteUser = (typeof autocompleteUsers)[number];
const identity = {
  getItemLabel: (user: AutocompleteUser) => user.name,
  getItemValue: (user: AutocompleteUser) => user.id,
};

export const AutocompleteDemo = () => {
  const [value, setValue] = useState("");
  return (
    <div className="grid w-64 max-w-full gap-2">
      <Autocomplete
        items={frameworks}
        label="Framework"
        placeholder="Search or type a framework"
        value={value}
        onValueChange={setValue}
        clearable
      />
      <output className="text-xs break-words text-muted-foreground">
        Text: {value || "(empty)"}
      </output>
    </div>
  );
};
export const AutocompleteFreeTextMultipleDemo = () => {
  const [tags, setTags] = useState<string[]>([]);
  return (
    <div className="grid w-80 max-w-full gap-2">
      <Autocomplete
        multiple
        items={frameworks}
        label="Tags"
        placeholder="Type and press Enter"
        value={tags}
        onValueChange={setTags}
        clearable
      />
      <output className="text-xs break-words text-muted-foreground">
        Committed: {JSON.stringify(tags)}
      </output>
    </div>
  );
};
export const AutocompleteSelectionDemo = () => {
  const [user, setUser] = useState<AutocompleteUser | null>(null);
  const [query, setQuery] = useState("");
  return (
    <div className="grid w-80 max-w-full gap-2">
      <Autocomplete
        mode="selection"
        items={autocompleteUsers}
        {...identity}
        value={user}
        onValueChange={setUser}
        inputValue={query}
        onInputValueChange={setQuery}
        label="Assignee"
        placeholder="Find a person"
        clearable
      />
      <output className="text-xs break-words text-muted-foreground">
        Committed ID: {user?.id ?? "null"}; query: {JSON.stringify(query)}
      </output>
    </div>
  );
};
export const AutocompleteSelectionMultipleDemo = () => {
  const [users, setUsers] = useState<AutocompleteUser[]>([]);
  return (
    <div className="grid w-80 max-w-full gap-2">
      <Autocomplete
        mode="selection"
        multiple
        items={autocompleteUsers}
        {...identity}
        value={users}
        onValueChange={setUsers}
        label="Assignees"
        placeholder="Find people"
        clearable
      />
      <output className="text-xs break-words text-muted-foreground">
        IDs in order: {JSON.stringify(users.map((user) => user.id))}
      </output>
    </div>
  );
};

Free-text tags

Enter commits a trimmed draft when no suggestion is highlighted. Empty strings and exact case-sensitive duplicates are rejected without callbacks. Choosing a suggestion follows the same rule. Commas and pasted text stay intact; blur and Tab never create tags. An uncommitted draft survives blur and Escape, but is not submitted.

Committed: []
autocomplete-demo.tsx
"use client";

import { useState } from "react";

import { Autocomplete } from "@/components/ui/autocomplete";

const frameworks = ["React", "Vue", "Svelte", "Solid"];
export const autocompleteUsers = [
  { id: "ada", name: "Ada Lovelace", team: "Engineering" },
  { id: "bea", name: "Bea Chen", team: "Design" },
  { id: "cam", name: "Cam Rivera", team: "Engineering" },
];
export type AutocompleteUser = (typeof autocompleteUsers)[number];
const identity = {
  getItemLabel: (user: AutocompleteUser) => user.name,
  getItemValue: (user: AutocompleteUser) => user.id,
};

export const AutocompleteDemo = () => {
  const [value, setValue] = useState("");
  return (
    <div className="grid w-64 max-w-full gap-2">
      <Autocomplete
        items={frameworks}
        label="Framework"
        placeholder="Search or type a framework"
        value={value}
        onValueChange={setValue}
        clearable
      />
      <output className="text-xs break-words text-muted-foreground">
        Text: {value || "(empty)"}
      </output>
    </div>
  );
};
export const AutocompleteFreeTextMultipleDemo = () => {
  const [tags, setTags] = useState<string[]>([]);
  return (
    <div className="grid w-80 max-w-full gap-2">
      <Autocomplete
        multiple
        items={frameworks}
        label="Tags"
        placeholder="Type and press Enter"
        value={tags}
        onValueChange={setTags}
        clearable
      />
      <output className="text-xs break-words text-muted-foreground">
        Committed: {JSON.stringify(tags)}
      </output>
    </div>
  );
};
export const AutocompleteSelectionDemo = () => {
  const [user, setUser] = useState<AutocompleteUser | null>(null);
  const [query, setQuery] = useState("");
  return (
    <div className="grid w-80 max-w-full gap-2">
      <Autocomplete
        mode="selection"
        items={autocompleteUsers}
        {...identity}
        value={user}
        onValueChange={setUser}
        inputValue={query}
        onInputValueChange={setQuery}
        label="Assignee"
        placeholder="Find a person"
        clearable
      />
      <output className="text-xs break-words text-muted-foreground">
        Committed ID: {user?.id ?? "null"}; query: {JSON.stringify(query)}
      </output>
    </div>
  );
};
export const AutocompleteSelectionMultipleDemo = () => {
  const [users, setUsers] = useState<AutocompleteUser[]>([]);
  return (
    <div className="grid w-80 max-w-full gap-2">
      <Autocomplete
        mode="selection"
        multiple
        items={autocompleteUsers}
        {...identity}
        value={users}
        onValueChange={setUsers}
        label="Assignees"
        placeholder="Find people"
        clearable
      />
      <output className="text-xs break-words text-muted-foreground">
        IDs in order: {JSON.stringify(users.map((user) => user.id))}
      </output>
    </div>
  );
};

Single selection

Objects require getItemLabel and getItemValue; the latter must return a unique stable string ID. Duplicate labels are allowed. Callbacks return the original current source item, not an ID. IDs compare newly fetched instances; an existing committed object remains displayable and submittable when absent from results.

Typing changes only inputValue, not the selection. Choosing replaces the committed item. Blur/Escape discards the query and restores the prior label (or empty input). Clear resets both. Controlled query owners must honor cleanup requests through onInputValueChange.

Committed ID: null; query: ""
autocomplete-demo.tsx
"use client";

import { useState } from "react";

import { Autocomplete } from "@/components/ui/autocomplete";

const frameworks = ["React", "Vue", "Svelte", "Solid"];
export const autocompleteUsers = [
  { id: "ada", name: "Ada Lovelace", team: "Engineering" },
  { id: "bea", name: "Bea Chen", team: "Design" },
  { id: "cam", name: "Cam Rivera", team: "Engineering" },
];
export type AutocompleteUser = (typeof autocompleteUsers)[number];
const identity = {
  getItemLabel: (user: AutocompleteUser) => user.name,
  getItemValue: (user: AutocompleteUser) => user.id,
};

export const AutocompleteDemo = () => {
  const [value, setValue] = useState("");
  return (
    <div className="grid w-64 max-w-full gap-2">
      <Autocomplete
        items={frameworks}
        label="Framework"
        placeholder="Search or type a framework"
        value={value}
        onValueChange={setValue}
        clearable
      />
      <output className="text-xs break-words text-muted-foreground">
        Text: {value || "(empty)"}
      </output>
    </div>
  );
};
export const AutocompleteFreeTextMultipleDemo = () => {
  const [tags, setTags] = useState<string[]>([]);
  return (
    <div className="grid w-80 max-w-full gap-2">
      <Autocomplete
        multiple
        items={frameworks}
        label="Tags"
        placeholder="Type and press Enter"
        value={tags}
        onValueChange={setTags}
        clearable
      />
      <output className="text-xs break-words text-muted-foreground">
        Committed: {JSON.stringify(tags)}
      </output>
    </div>
  );
};
export const AutocompleteSelectionDemo = () => {
  const [user, setUser] = useState<AutocompleteUser | null>(null);
  const [query, setQuery] = useState("");
  return (
    <div className="grid w-80 max-w-full gap-2">
      <Autocomplete
        mode="selection"
        items={autocompleteUsers}
        {...identity}
        value={user}
        onValueChange={setUser}
        inputValue={query}
        onInputValueChange={setQuery}
        label="Assignee"
        placeholder="Find a person"
        clearable
      />
      <output className="text-xs break-words text-muted-foreground">
        Committed ID: {user?.id ?? "null"}; query: {JSON.stringify(query)}
      </output>
    </div>
  );
};
export const AutocompleteSelectionMultipleDemo = () => {
  const [users, setUsers] = useState<AutocompleteUser[]>([]);
  return (
    <div className="grid w-80 max-w-full gap-2">
      <Autocomplete
        mode="selection"
        multiple
        items={autocompleteUsers}
        {...identity}
        value={users}
        onValueChange={setUsers}
        label="Assignees"
        placeholder="Find people"
        clearable
      />
      <output className="text-xs break-words text-muted-foreground">
        IDs in order: {JSON.stringify(users.map((user) => user.id))}
      </output>
    </div>
  );
};

Multiple selection

Only known items can be selected. New choices append in insertion order; duplicate IDs are rejected. Query cleanup never emits a value change. Blur/Escape discards the query, keeping all choices. Successful additions clear the query and keep the popup open.

IDs in order: []
autocomplete-demo.tsx
"use client";

import { useState } from "react";

import { Autocomplete } from "@/components/ui/autocomplete";

const frameworks = ["React", "Vue", "Svelte", "Solid"];
export const autocompleteUsers = [
  { id: "ada", name: "Ada Lovelace", team: "Engineering" },
  { id: "bea", name: "Bea Chen", team: "Design" },
  { id: "cam", name: "Cam Rivera", team: "Engineering" },
];
export type AutocompleteUser = (typeof autocompleteUsers)[number];
const identity = {
  getItemLabel: (user: AutocompleteUser) => user.name,
  getItemValue: (user: AutocompleteUser) => user.id,
};

export const AutocompleteDemo = () => {
  const [value, setValue] = useState("");
  return (
    <div className="grid w-64 max-w-full gap-2">
      <Autocomplete
        items={frameworks}
        label="Framework"
        placeholder="Search or type a framework"
        value={value}
        onValueChange={setValue}
        clearable
      />
      <output className="text-xs break-words text-muted-foreground">
        Text: {value || "(empty)"}
      </output>
    </div>
  );
};
export const AutocompleteFreeTextMultipleDemo = () => {
  const [tags, setTags] = useState<string[]>([]);
  return (
    <div className="grid w-80 max-w-full gap-2">
      <Autocomplete
        multiple
        items={frameworks}
        label="Tags"
        placeholder="Type and press Enter"
        value={tags}
        onValueChange={setTags}
        clearable
      />
      <output className="text-xs break-words text-muted-foreground">
        Committed: {JSON.stringify(tags)}
      </output>
    </div>
  );
};
export const AutocompleteSelectionDemo = () => {
  const [user, setUser] = useState<AutocompleteUser | null>(null);
  const [query, setQuery] = useState("");
  return (
    <div className="grid w-80 max-w-full gap-2">
      <Autocomplete
        mode="selection"
        items={autocompleteUsers}
        {...identity}
        value={user}
        onValueChange={setUser}
        inputValue={query}
        onInputValueChange={setQuery}
        label="Assignee"
        placeholder="Find a person"
        clearable
      />
      <output className="text-xs break-words text-muted-foreground">
        Committed ID: {user?.id ?? "null"}; query: {JSON.stringify(query)}
      </output>
    </div>
  );
};
export const AutocompleteSelectionMultipleDemo = () => {
  const [users, setUsers] = useState<AutocompleteUser[]>([]);
  return (
    <div className="grid w-80 max-w-full gap-2">
      <Autocomplete
        mode="selection"
        multiple
        items={autocompleteUsers}
        {...identity}
        value={users}
        onValueChange={setUsers}
        label="Assignees"
        placeholder="Find people"
        clearable
      />
      <output className="text-xs break-words text-muted-foreground">
        IDs in order: {JSON.stringify(users.map((user) => user.id))}
      </output>
    </div>
  );
};

Rich items and composition

renderItem changes visual content, not identity, search labels or submitted values. groupBy preserves first-seen group order and source order within each group. Empty groups are omitted. isItemDisabled prevents choosing a suggestion.

autocomplete-advanced-demo.tsx
"use client";

import {
  Autocomplete,
  AutocompleteRoot,
  AutocompleteInput,
  AutocompleteContent,
  AutocompleteList,
  AutocompleteGroup,
  AutocompleteGroupLabel,
  AutocompleteItem,
  AutocompleteEmpty,
} from "@/components/ui/autocomplete";

const people = [
  {
    description: "Analytical engines",
    id: "ada",
    name: "Ada Lovelace",
    team: "Engineering",
  },
  {
    description: "Product systems",
    id: "bea",
    name: "Bea Chen",
    team: "Design",
  },
  {
    description: "Unavailable",
    disabled: true,
    id: "cam",
    name: "Cam Rivera",
    team: "Engineering",
  },
];
export const AutocompleteAdvancedDemo = () => (
  <div className="grid w-80 max-w-full gap-5">
    <Autocomplete
      mode="selection"
      items={people}
      getItemLabel={(person) => person.name}
      getItemValue={(person) => person.id}
      groupBy={(person) => person.team}
      isItemDisabled={(person) => Boolean(person.disabled)}
      label="Team member"
      renderItem={(person) => (
        <span className="grid gap-0.5">
          <span>{person.name}</span>
          <span className="text-xs text-muted-foreground">
            {person.description}
          </span>
        </span>
      )}
    />
    <AutocompleteRoot items={["React", "Vue", "Svelte"]}>
      <label htmlFor="composed-framework" className="text-sm font-medium">
        Composed framework
      </label>
      <AutocompleteInput id="composed-framework" placeholder="Search or type" />
      <AutocompleteContent>
        <AutocompleteEmpty />
        <AutocompleteList>
          <AutocompleteGroup>
            <AutocompleteGroupLabel>Frameworks</AutocompleteGroupLabel>
            {["React", "Vue", "Svelte"].map((item) => (
              <AutocompleteItem key={item} value={item}>
                {item}
              </AutocompleteItem>
            ))}
          </AutocompleteGroup>
        </AutocompleteList>
      </AutocompleteContent>
    </AutocompleteRoot>
  </div>
);

Named parts: AutocompleteRoot, AutocompleteInput, AutocompleteContent, AutocompleteList, AutocompleteItem, AutocompleteGroup, AutocompleteGroupLabel, AutocompleteEmpty, AutocompleteStatus, AutocompleteClear, AutocompleteTrigger, AutocompleteChips, AutocompleteChip, AutocompleteChipRemove.

The Root uses the same four-mode contract, not an arbitrary Base UI pass-through. Provide items for identity/filtering. Pass original items to AutocompleteItem.value; put items in List and Group. Chips are supported only under multiple Root, inside AutocompleteChips, in committed-value order. AutocompleteChip.label is required; removal uses its label for the accessible name. In compound usage, supply your own associated label or input aria-label, and your layout/classes. Status and Empty are optional, mode-aware parts. Do not mix parts from unrelated primitive roots. Root's groupBy/renderItem do not assemble compound children; those are convenience assembly options.

Async suggestions

Applications own debounce, requests, cancellation, stale-response protection and caching. Set filter={null} for server-filtered results. Default filtering is case/accent-insensitive label matching; a custom filter(item, query) can replace it.

Error takes precedence over loading, then empty, then populated results. Loading/error suppress stale suggestion commits, but never erase choices or disable free-text edits/creation. Status feedback uses a polite region; no “no results” appears while loading/error is active. The component does not fetch or own retry logic.

Committed: null

300ms debounce, deterministic local service. “slow” simulates a delayed result; “error” fails predictably.

autocomplete-async-demo.tsx
"use client";

// Timer-backed cancellable mock service needs a Promise constructor.
/* eslint-disable promise/avoid-new */

import { useEffect, useState } from "react";

import { Autocomplete } from "@/components/ui/autocomplete";

import { autocompleteUsers } from "./autocomplete-demo";
import type { AutocompleteUser } from "./autocomplete-demo";

// Simulated service: deterministic delay/failure, cancellable timer. Applications
// replace this with fetch(url, { signal }) and retain the same ownership boundary.
const searchPeople = (
  query: string,
  signal: AbortSignal
  // Timer-backed cancellable mock service needs a Promise constructor.
  // eslint-disable-next-line promise/avoid-new
): Promise<AutocompleteUser[]> =>
  new Promise((resolve, reject) => {
    const abort = () => {
      // The callback runs after the timer is initialized.
      // eslint-disable-next-line no-use-before-define
      clearTimeout(timer);
      reject(new DOMException("Aborted", "AbortError"));
    };
    const timer = setTimeout(
      () => {
        signal.removeEventListener("abort", abort);
        if (query.toLowerCase() === "error") {
          reject(
            new Error("Suggestions unavailable. Change the query to retry.")
          );
        } else {
          resolve(
            query === "slow"
              ? [autocompleteUsers[0]]
              : autocompleteUsers.filter((person) =>
                  person.name.toLowerCase().includes(query.toLowerCase())
                )
          );
        }
      },
      query === "slow" ? 700 : 120
    );
    signal.addEventListener("abort", abort, { once: true });
  });
export const AutocompleteAsyncDemo = () => {
  const [value, setValue] = useState<AutocompleteUser | null>(null);
  const [query, setQuery] = useState("");
  const [items, setItems] = useState<AutocompleteUser[]>([]);
  const [loading, setLoading] = useState(false);
  const [suggestionError, setError] = useState<string | undefined>();
  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);
    setError(undefined);
    const search = async () => {
      try {
        const results = await searchPeople(query, controller.signal);
        if (!controller.signal.aborted) {
          setItems(results);
          setLoading(false);
        }
      } catch (error) {
        if (!controller.signal.aborted) {
          setError(
            error instanceof Error ? error.message : "Suggestions unavailable."
          );
          setLoading(false);
        }
      }
    };
    const timer = setTimeout(() => {
      void search();
    }, 300);
    return () => {
      clearTimeout(timer);
      controller.abort();
    };
  }, [query]);
  return (
    <div className="grid w-80 max-w-full gap-2">
      <Autocomplete
        mode="selection"
        items={items}
        getItemLabel={(person) => person.name}
        getItemValue={(person) => person.id}
        value={value}
        onValueChange={setValue}
        inputValue={query}
        onInputValueChange={setQuery}
        filter={null}
        loading={loading}
        error={suggestionError}
        label="Async assignee"
        placeholder='Search people or type "error"'
        clearable
      />
      <output className="text-xs text-muted-foreground">
        Committed: {value?.id ?? "null"}
      </output>
      <p className="text-xs text-muted-foreground">
        300ms debounce, deterministic local service. “slow” simulates a delayed
        result; “error” fails predictably.
      </p>
    </div>
  );
};

This deterministic local service debounces for 300ms, aborts timers and ignores stale responses. Type slow to delay a result or error to see a predictable failure. Replace it with your own service, retaining cleanup.

Optional Query integration

Import useAutocompleteQuery or useAutocompleteInfiniteQuery from @/components/autocomplete/query. Install @tanstack/react-query@^5.104.1 and provide your own QueryClientProvider; no hidden provider is installed.

Type “error” once to exercise Retry, or “slow” for delayed results.

autocomplete-query-demo.tsx
"use client";

import {
  QueryClient,
  QueryClientProvider,
  queryOptions,
} from "@tanstack/react-query";
import { useState } from "react";

import { Autocomplete } from "../registry/new-york/autocomplete";
import { useAutocompleteQuery } from "../registry/new-york/autocomplete-query";

const names = [
  "Ada Lovelace",
  "Grace Hopper",
  "Margaret Hamilton",
  "Katherine Johnson",
  "Alan Turing",
  "Barbara Liskov",
];
export const searchAutocompletePeople = async (
  search: string,
  signal: AbortSignal
) => {
  await new Promise<void>((resolve, reject) => {
    const abort = () => {
      clearTimeout(timer);
      reject(new DOMException("Aborted", "AbortError"));
    };
    const timer = setTimeout(
      () => {
        signal.removeEventListener("abort", abort);
        resolve();
      },
      search === "slow" ? 600 : 100
    );
    if (signal.aborted) {
      abort();
    } else {
      signal.addEventListener("abort", abort, { once: true });
    }
  });
  return names
    .map((name, index) => ({ id: String(index), name }))
    .filter(
      (person) =>
        search === "slow" ||
        search === "error" ||
        person.name.toLowerCase().includes(search.toLowerCase())
    );
};
type Person = { id: string; name: string };
const QueryExample = () => {
  const [selected, setSelected] = useState<Person | null>(null);
  const [failed, setFailed] = useState(false);
  const suggestions = useAutocompleteQuery({
    mode: "selection",
    minSearchLength: 2,
    queryOptions: ({ search }) =>
      queryOptions({
        queryKey: ["demo-people", search],
        retry: false,
        queryFn: async ({ signal }) => {
          if (search === "error" && !failed) {
            setFailed(true);
            throw new Error("Predictable initial failure");
          }
          return searchAutocompletePeople(search, signal);
        },
      }),
    getItems: (data) => data,
  });
  return (
    <div className="grid w-80 max-w-full gap-2">
      <Autocomplete
        {...suggestions.autocompleteProps}
        label="Person"
        value={selected}
        onValueChange={setSelected}
        getItemLabel={(person) => person.name}
        getItemValue={(person) => person.id}
        loadingProps={{ variant: "dots", size: 16 }}
      />
      <p className="text-xs text-muted-foreground">
        Type “error” once to exercise Retry, or “slow” for delayed results.
      </p>
    </div>
  );
};
export const AutocompleteQueryDemo = () => {
  const [client] = useState(() => new QueryClient());
  return (
    <QueryClientProvider client={client}>
      <QueryExample />
    </QueryClientProvider>
  );
};

Both hooks accept native options factories receiving debounced search, plus getItems. Infinite getItems receives each selected page, including a page shape changed by select, and object items require stable getItemValue IDs. Duplicate IDs retain the first object and order without mutating cached pages. Include search and every request identity in your query key.

debounceMs defaults to 300 and minSearchLength to 0. Fetching requires an open popup, sufficient search length, settled debounce, hook enabled, and native boolean/function enabled and skipToken semantics. UI Retry and Load more cannot bypass these gates. Native Query results remain exposed as query for applications making their own decisions. Invalid configuration throws RangeError.

Use search/defaultSearch/onSearchChange and open/defaultOpen/onOpenChange for controlled or uncontrolled state. Selection and free-text tags bind only the query; applications own committed value/onValueChange. Single free text binds search as value/onValueChange, never inputValue; controlled search makes text application-owned. Hook setters request changes without overriding controlled props.

Consume signal in query functions for Query cancellation. Closing the popup alone does not guarantee abortion. Distinct keys prevent mixing search generations; previous-key placeholders are not selectable. Same-search cached results remain selectable during background refresh. formatError returns safe compact content; raw Error objects are never rendered.

Second page fails once. Retry keeps your selections and first-page results.

autocomplete-infinite-demo.tsx
"use client";

import {
  QueryClient,
  QueryClientProvider,
  infiniteQueryOptions,
} from "@tanstack/react-query";
import { useRef, useState } from "react";

import { Autocomplete } from "../registry/new-york/autocomplete";
import { useAutocompleteInfiniteQuery } from "../registry/new-york/autocomplete-query";
import { searchAutocompletePeople } from "./autocomplete-query-demo";

const InfiniteExample = () => {
  const [selected, setSelected] = useState<{ id: string; name: string }[]>([]);
  const failed = useRef(false);
  const suggestions = useAutocompleteInfiniteQuery({
    mode: "selection",
    multiple: true,
    queryOptions: ({ search }) =>
      infiniteQueryOptions({
        queryKey: ["demo-pages", search],
        retry: false,
        initialPageParam: 0,
        queryFn: async ({ pageParam, signal }) => {
          const people = await searchAutocompletePeople(search, signal);
          if (pageParam === 2 && !failed.current) {
            failed.current = true;
            throw new Error("Predictable page failure");
          }
          return {
            people: people.slice(pageParam, pageParam + 2),
            next: pageParam + 2 < people.length ? pageParam + 2 : undefined,
          };
        },
        getNextPageParam: (page) => page.next,
      }),
    getItems: (page) => page.people,
    getItemValue: (person) => person.id,
  });
  return (
    <div className="grid w-80 max-w-full gap-2">
      <Autocomplete
        {...suggestions.autocompleteProps}
        label="People"
        value={selected}
        onValueChange={setSelected}
        getItemLabel={(person) => person.name}
        getItemValue={(person) => person.id}
      />
      <p className="text-xs text-muted-foreground">
        Second page fails once. Retry keeps your selections and first-page
        results.
      </p>
    </div>
  );
};
export const AutocompleteInfiniteDemo = () => {
  const [client] = useState(() => new QueryClient());
  return (
    <QueryClientProvider client={client}>
      <InfiniteExample />
    </QueryClientProvider>
  );
};

The actual list viewport triggers automatic loading near its bottom only after scrolling. Mounting a short list does not drain pages. An accessible type="button" Load more remains available; page failure latches automatic retry until explicit Retry or query change. Existing results and selections remain during page loading, page errors and background refresh. Infinite scrolling is not virtualization.

StateCompact presentation
Below minimumHint, old options blocked
Debouncing / initial fetchPublic Loading and text
Initial errorError and Retry
Successful emptyDedicated empty message
Same-search background fetchRetained options and Loading
Next page fetching / errorRetained options, footer Loading / Retry
More / exhaustedLoad more / no action

Customize loadingProps={{ variant: "dots", size: 16 }}, hintMessage, loadingMessage, emptyMessage, retryLabel, and pagination labels/messages. Compound Root uses the same bindings with AutocompleteFeedback and AutocompletePagination. Core feedback does not use registry ErrorState or Empty. Playground pagination scenarios simulate states, not real API requests; the two Query examples above use deterministic services and real Query caches.

Forms and reset

Single free text submits its string; selection submits the stable ID. Multiple values submit repeated name entries in insertion order. Empty multiples submit no entries; empty singles submit "". Drafts never submit. form supports an external form ID. Disabled controls are excluded; read-only controls remain focusable and submitted.

required checks a committed selection/tag, not a typed query. The editable input receives validity feedback. Native reset restores uncontrolled defaults and initial drafts, closes the popup and resets transient state. Controlled state remains application-owned; the example's onReset explicitly restores its controlled value.

Searching is not selecting. Required checks the committed choice.

Submit to inspect committed entries.
autocomplete-form-demo.tsx
"use client";

import { useRef, useState } from "react";

import { Autocomplete } from "@/components/ui/autocomplete";

export const AutocompleteFormDemo = () => {
  const [text, setText] = useState("React");
  const [result, setResult] = useState("Submit to inspect committed entries.");
  const ref = useRef<HTMLInputElement | null>(null);
  return (
    <form
      className="grid w-80 max-w-full gap-4"
      onSubmit={(event) => {
        event.preventDefault();
        setResult(
          JSON.stringify([...new FormData(event.currentTarget).entries()])
        );
      }}
      onReset={() => {
        setText("React");
        setResult("Defaults restored.");
      }}
    >
      <Autocomplete
        mode="selection"
        items={["React", "Vue", "Svelte"]}
        name="framework"
        label="Required framework"
        required
        inputProps={{ "aria-describedby": "autocomplete-form-help" }}
        ref={ref}
      />
      <p id="autocomplete-form-help" className="text-xs text-muted-foreground">
        Searching is not selecting. Required checks the committed choice.
      </p>
      <Autocomplete
        multiple
        items={["React", "Vue"]}
        name="tags"
        label="Uncontrolled tags"
        defaultValue={["React"]}
        defaultInputValue="draft"
        clearable
      />
      <Autocomplete
        items={["React", "Vue"]}
        name="controlled"
        label="Controlled text"
        value={text}
        onValueChange={setText}
      />
      <Autocomplete
        items={[]}
        name="reference"
        label="Read-only reference"
        value="Editable source"
        readOnly
      />
      <Autocomplete
        items={[]}
        name="excluded"
        label="Disabled (not submitted)"
        defaultValue="Excluded"
        disabled
      />
      <div className="flex flex-wrap gap-3">
        <button type="submit" className="text-sm underline">
          Submit
        </button>
        <button type="reset" className="text-sm underline">
          Reset
        </button>
        <button
          type="button"
          className="text-sm underline"
          onClick={() => ref.current?.focus()}
        >
          Focus framework
        </button>
      </div>
      <output className="text-xs break-words text-muted-foreground">
        {result}
      </output>
    </form>
  );
};

Behavior & Accessibility

Inside Drawer and Dialog

autocomplete-overlay-demo.tsx
"use client";

import { Autocomplete } from "@/components/ui/autocomplete";
import { Button } from "@/components/ui/button";
import { Dialog } from "@/components/ui/dialog";
import { Drawer } from "@/components/ui/drawer";

const items = ["React", "Vue", "Svelte", "Solid"];

export const AutocompleteOverlayDemo = () => (
  <div className="flex flex-wrap gap-2">
    <Drawer
      title="Choose framework"
      trigger={<Button variant="outline">Open Drawer</Button>}
    >
      <Autocomplete
        items={items}
        label="Framework"
        labelStyle="floating"
        showTrigger
        clearable
      />
    </Drawer>
    <Dialog
      title="Choose framework"
      trigger={<Button variant="outline">Open Dialog</Button>}
    >
      <Autocomplete
        items={items}
        label="Framework"
        labelStyle="floating"
        showTrigger
        clearable
      />
    </Dialog>
  </div>
);

Install Button, Drawer and Dialog separately for this example. Suggestions keep their isolated overlay layer; selecting one updates the field without dismissing the parent. Use Tab/Shift+Tab, Escape and arrow keys to inspect focus and dismissal. Popup painting and collision need a real browser; DOM tests do not establish their appearance.

  • Give the field a label or input ARIA name; placeholder alone is not a label. Static labels are default, floating labels opt in.
  • Default height is 36px, size="sm" is 32px. Multiple chips expand and wrap the field. Popup width follows the anchor and its list scrolls within available viewport height.
  • className owns the outer convenience field. inputProps owns native attributes, descriptions, handlers and ref; managed value/form/type/disabled/readOnly/required/size props are excluded. contentProps owns popup props and positioning/collision options, excluding children/render. Top-level ref targets the editable input and is merged with inputProps.ref.
  • invalid marks the input; connect help/error text using inputProps["aria-describedby"]. Suggestion error is not automatically a validation error.
  • Arrow keys navigate suggestions, Enter selects highlighted suggestions. A handled Enter does not submit the form. Ordinary single free-text Enter without a highlighted suggestion retains native submission behavior.
  • IME Enter cannot select/create; the composition-ending Enter is guarded. No comma delimiters or paste splitting.
  • Empty-draft Backspace focuses the last chip first. Delete/Backspace on that chip removes it and focuses the prior chip or input. Removal actions have accessible names and stay keyboard reachable.
  • Read-only blocks choosing, clear, removal and value-changing shortcuts without excluding submission. Disabled blocks interaction and native submission.
  • Consumer handlers are composed; preventDefault() cancels adapter behavior and underlying Base UI handlers. Public Base UI preventBaseUIHandler() is also supported on compound primitive events.
  • onValueChange fires only on real edits/commits/removals/clear, never opening, highlighting, failed duplicates or query-only edits. Callbacks request controlled changes; no cancelable event-details contract is promised.
  • clearable defaults false; trigger defaults on for selection, off for free text; autoHighlight defaults false. animated defaults true; popup fade/scale uses 180ms opening and 120ms closing, respecting reduced motion. Query edits are not animated.

Inline completion, virtualization, tag reordering and creating selection objects are not part of this release. Underlying Base UI Autocomplete and Combobox supply list navigation; Vandor mode is not Base UI's inline-completion mode.

Portable Storybook stories

Install Autocomplete first, then optionally install its stories:

node scripts/install-component-family.cjs autocomplete-stories /absolute/path/to/consumer

This adds only colocated autocomplete.stories.tsx in the configured components family directory. It never reinstalls the component, adds/upgrades Storybook packages or modifies .storybook/. Supply matching Storybook React packages; verified types/composition target Storybook 10.6.1. Discover **/*.stories.tsx, import your own Tailwind/shadcn global stylesheet and theme in the preview, and retain your existing decorators. Static, floating, unlabeled and multiple-label stories are included; KeyboardSelection provides a reproducible manual keyboard flow, not simulated browser focus. No optional addon is required. Ordinary component installation is Storybook-free. Storybook UI not verified: this project has no Storybook server.

Props

Component props, types, defaults, and descriptions
PropType / DescriptionDefault
animatedboolean

Animated

true
autoHighlightboolean

Auto highlight

false
classNamestring

Convenience outer field container.

Not set
clearLabelstring

Accessible clear action name.

"Clear value"
clearableboolean

Clearable

false
contentPropsAutocompleteContentProps

Popup attributes/ref/class/style and positioning/collision options; excludes children/render.

bottom, start, offset 4
defaultInputValuestring

Initial uncontrolled draft/query except single free text.

Selected label or empty
defaultOpenboolean

Initial popup state.

false
defaultValueSame as value

Initial uncontrolled committed value restored on native reset.

Mode-specific empty
disabledboolean

Disabled

false
emptyMessageReactNode

Empty suggestion status.

"No results found."
errorReactNode

Suggestion error; takes precedence over loading and empty.

Not set
filter((item: Item, query: string) => boolean) | null

null disables filtering for server results.

Case/accent insensitive label matching
formstring

External native form ID, including reset ownership.

Nearest form
getItemLabel(item: Item) => string

Required for objects; display and default filtering label.

String item
getItemValue(item: Item) => string

Required for objects; unique stable string identity and form value.

String item
groupBy(item: Item) => string

Convenience assembly: first-seen group order, original order inside groups.

Not set
groupingboolean

Grouping

false
inputPropsAutocompleteInputProps

Native input attributes, handlers, descriptions and ref; excludes managed state/form/type props.

Not set
inputValuestring

Controlled draft/query, forbidden for single free text.

Not set
invalidboolean

Invalid

false
isItemDisabled(item: Item) => boolean

Disable an individual suggestion.

Not set
itemsreadonly Item[]

Flat source collection. Strings work without accessors; objects require both accessors.

Required
labelstring

Label

Not set
labelStyle"static" | "floating"

Label style

"static"
loadingboolean

Suppress stale result commits, preserve text editing and free-text creation.

false
loadingVariant"arc" | "dots"

Loading variant

"arc"
automaticPaginationboolean

Automatic pagination

true
hasNextPageboolean

Has next page

true
loadingMessageReactNode

Loading suggestion status.

"Loading suggestions…"
mode"free-text" | "selection"

Mode

"free-text"
multipleboolean

Multiple

false
namestring

Committed form value, repeated entries for multiple; queries never submit.

Not set
onInputValueChange(query: string) => void

Query edits and cleanup requests; never changes committed values.

Not set
onOpenChange(open: boolean) => void

Popup change request.

Not set
onValueChange(value) => void

Real edits, commits, removals and clear; callback type follows literal mode/multiple.

Not set
openboolean

Controlled popup state.

Not set
placeholderstring

Placeholder

Not set
readOnlyboolean

Read only

false
refRef<HTMLInputElement>

Editable input, merged with inputProps.ref.

Not set
removeLabel(label: string) => string

Accessible chip removal action name.

"Remove {label}"
renderItem(item: Item) => ReactNode

Convenience assembly: visual content only, not identity or filtering.

Item label
requiredboolean

Requires committed value, not an uncommitted query.

false
scenario"ready" | "loading" | "error" | "empty" | "hint" | "background" | "more" | "page-loading" | "page-error" | "end"

Status scenario

"ready"
showTriggerboolean

Show trigger

true in selection; false in free-text
size"default" | "sm"

Size

"default"
valuestring | string[] | Item | null | Item[]

Committed value. Controlled state stays authoritative.

Mode-specific empty

Autocomplete owns its managed value/query/form state. inputProps forwards input attributes, handlers, ARIA descriptions and ref except managed state, form, disabled/readOnly/required, type and size. contentProps forwards popup attributes and listed positioning/collision options, excluding children/render. Compound parts use the same root contract; chips require multiple.

Source

import { cn } from "cn";
import type { ReactNode } from "react";
 
/** Accessible visual outline shared by labeled registry field triggers. */
export const FieldOutline = ({
  children,
  label,
  labelId,
  raised = false,
  staticLabel = false,
  className,
  htmlFor,
}: {
  children: ReactNode;
  label: string;
  labelId: string;
  raised?: boolean;
  staticLabel?: boolean;
  className?: string;
  htmlFor?: string;
}) => (
  <div
    className={cn("group/outline relative min-w-0", className)}
    data-field-outline=""
    data-raised={raised || staticLabel || undefined}
  >
    {label &&
      (staticLabel ? (
        <label
          htmlFor={htmlFor}
          id={labelId}
          className="mb-1.5 block text-sm font-medium"
        >
          {label}
        </label>
      ) : (
        <>
          <label
            id={labelId}
            htmlFor={htmlFor}
            data-floating={raised}
            className={cn(
              "pointer-events-none absolute z-10 max-w-[calc(100%-1rem)] -translate-y-1/2 truncate px-1 text-muted-foreground transition-[top,font-size] duration-150 motion-reduce:transition-none",
              raised
                ? "top-0 left-3 text-xs"
                : "top-1/2 left-3 text-sm group-focus-within/outline:top-0 group-focus-within/outline:text-xs"
            )}
          >
            {label}
          </label>
          <fieldset
            aria-hidden="true"
            className="pointer-events-none absolute inset-x-0 -top-1.5 bottom-0 min-w-0 rounded-md border border-input px-2 group-focus-within/outline:border-ring group-has-[[aria-invalid=true]]/outline:border-destructive group-has-[:disabled]/outline:opacity-50"
          >
            <legend
              className={cn(
                "invisible block h-3 max-w-0 overflow-hidden whitespace-nowrap p-0 text-xs leading-3 transition-[max-width] duration-150 group-focus-within/outline:max-w-full motion-reduce:transition-none",
                raised && "max-w-full"
              )}
            >
              <span className="px-1">{label}</span>
            </legend>
          </fieldset>
        </>
      ))}
    {children}
  </div>
);
 

Credits

  • ReUI: Design and API reference only. Vandor's adapter and styling are independently authored, not copied ReUI source.
  • Base UI: Public autocomplete, combobox and chip primitives provide accessible interaction.