- Accordion
- AppShell
- Autocomplete
- Badge
- Blobatar
- BoringAvatar
- Button
- Calendar
- Checkbox
- DataGrid
- Date Picker
- Date Range Picker
- DateTime
- Dialog
- Drawer
- Dropdown
- Empty
- Error State
- Frame
- Input
- Input Amount
- Input Group
- Input OTP
- Input Password
- Input Phone
- Input Search
- Input Secret
- Loading
- PageContainer
- PageHeader
- Popover
- Select
- StatsCard
- Switch
- Tabs
- TextArea
- Toast
- Tooltip
Playground
"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/consumerDependencies
Installed automatically by the registry CLI. For manual setup:
- @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.0Usage
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/consumerThe 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
| Mode | Multiple | Committed value | Empty |
|---|---|---|---|
free-text (default) | false | string | "" |
free-text | true | string[] | [] |
selection | false | original Item | null |
selection | true | original 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.
"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.
"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.
"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.
"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.
"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.
300ms debounce, deterministic local service. “slow” simulates a delayed result; “error” fails predictably.
"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.
"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.
"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.
| State | Compact presentation |
|---|---|
| Below minimum | Hint, old options blocked |
| Debouncing / initial fetch | Public Loading and text |
| Initial error | Error and Retry |
| Successful empty | Dedicated empty message |
| Same-search background fetch | Retained options and Loading |
| Next page fetching / error | Retained options, footer Loading / Retry |
| More / exhausted | Load 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.
"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
"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
labelor 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. classNameowns the outer convenience field.inputPropsowns native attributes, descriptions, handlers and ref; managed value/form/type/disabled/readOnly/required/size props are excluded.contentPropsowns popup props and positioning/collision options, excluding children/render. Top-level ref targets the editable input and is merged withinputProps.ref.invalidmarks the input; connect help/error text usinginputProps["aria-describedby"]. Suggestionerroris 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 UIpreventBaseUIHandler()is also supported on compound primitive events. onValueChangefires 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.clearabledefaults false; trigger defaults on for selection, off for free text;autoHighlightdefaults false.animateddefaults 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/consumerThis 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
| Prop | Type / Description | Default |
|---|---|---|
| animated | booleanAnimated | true |
| autoHighlight | booleanAuto highlight | false |
| className | stringConvenience outer field container. | Not set |
| clearLabel | stringAccessible clear action name. | "Clear value" |
| clearable | booleanClearable | false |
| contentProps | AutocompleteContentPropsPopup attributes/ref/class/style and positioning/collision options; excludes children/render. | bottom, start, offset 4 |
| defaultInputValue | stringInitial uncontrolled draft/query except single free text. | Selected label or empty |
| defaultOpen | booleanInitial popup state. | false |
| defaultValue | Same as valueInitial uncontrolled committed value restored on native reset. | Mode-specific empty |
| disabled | booleanDisabled | false |
| emptyMessage | ReactNodeEmpty suggestion status. | "No results found." |
| error | ReactNodeSuggestion error; takes precedence over loading and empty. | Not set |
| filter | ((item: Item, query: string) => boolean) | nullnull disables filtering for server results. | Case/accent insensitive label matching |
| form | stringExternal native form ID, including reset ownership. | Nearest form |
| getItemLabel | (item: Item) => stringRequired for objects; display and default filtering label. | String item |
| getItemValue | (item: Item) => stringRequired for objects; unique stable string identity and form value. | String item |
| groupBy | (item: Item) => stringConvenience assembly: first-seen group order, original order inside groups. | Not set |
| grouping | booleanGrouping | false |
| inputProps | AutocompleteInputPropsNative input attributes, handlers, descriptions and ref; excludes managed state/form/type props. | Not set |
| inputValue | stringControlled draft/query, forbidden for single free text. | Not set |
| invalid | booleanInvalid | false |
| isItemDisabled | (item: Item) => booleanDisable an individual suggestion. | Not set |
| items | readonly Item[]Flat source collection. Strings work without accessors; objects require both accessors. | Required |
| label | stringLabel | Not set |
| labelStyle | "static" | "floating"Label style | "static" |
| loading | booleanSuppress stale result commits, preserve text editing and free-text creation. | false |
| loadingVariant | "arc" | "dots"Loading variant | "arc" |
| automaticPagination | booleanAutomatic pagination | true |
| hasNextPage | booleanHas next page | true |
| loadingMessage | ReactNodeLoading suggestion status. | "Loading suggestions…" |
| mode | "free-text" | "selection"Mode | "free-text" |
| multiple | booleanMultiple | false |
| name | stringCommitted form value, repeated entries for multiple; queries never submit. | Not set |
| onInputValueChange | (query: string) => voidQuery edits and cleanup requests; never changes committed values. | Not set |
| onOpenChange | (open: boolean) => voidPopup change request. | Not set |
| onValueChange | (value) => voidReal edits, commits, removals and clear; callback type follows literal mode/multiple. | Not set |
| open | booleanControlled popup state. | Not set |
| placeholder | stringPlaceholder | Not set |
| readOnly | booleanRead only | false |
| ref | Ref<HTMLInputElement>Editable input, merged with inputProps.ref. | Not set |
| removeLabel | (label: string) => stringAccessible chip removal action name. | "Remove {label}" |
| renderItem | (item: Item) => ReactNodeConvenience assembly: visual content only, not identity or filtering. | Item label |
| required | booleanRequires committed value, not an uncommitted query. | false |
| scenario | "ready" | "loading" | "error" | "empty" | "hint" | "background" | "more" | "page-loading" | "page-error" | "end"Status scenario | "ready" |
| showTrigger | booleanShow trigger | true in selection; false in free-text |
| size | "default" | "sm"Size | "default" |
| value | string | 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
On This Page
PlaygroundInstallationDependenciesUsageFour value contractsFree textFree-text tagsSingle selectionMultiple selectionRich items and compositionAsync suggestionsOptional Query integrationForms and resetBehavior & AccessibilityInside Drawer and DialogPortable Storybook storiesPropsSourceCredits