AsyncAutocomplete
FormRemote-style search with cancellation, grouped results, and clear loading, empty, and error states.
Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.
"use client";
import { useCallback, useState } from "react";
import {
AsyncAutocomplete,
type AutocompleteOption,
} from "@/components/ui/async-autocomplete";
import { Button } from "@dashboardpack/core/components/ui/button";
const directory: AutocompleteOption[] = [
{
value: "alex",
label: "Alex Morgan",
description: "Product designer · Studio",
group: "Design",
},
{
value: "alice",
label: "Alice Chen",
description: "Design systems · Studio",
group: "Design",
},
{
value: "sam",
label: "Sam Rivera",
description: "Frontend engineer · Platform",
group: "Engineering",
},
{
value: "jordan",
label: "Jordan Lee",
description: "Platform lead · On leave",
group: "Engineering",
disabled: true,
},
];
export default function AsyncAutocompleteExample() {
const [value, setValue] = useState<AutocompleteOption | null>(null);
const [failure, setFailure] = useState(false);
const load = useCallback(
async (query: string, signal: AbortSignal) => {
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, 400);
signal.addEventListener(
"abort",
() => {
clearTimeout(timer);
reject(new DOMException("Aborted", "AbortError"));
},
{ once: true },
);
});
if (failure) throw new Error("Demo unavailable");
return directory.filter((person) =>
`${person.label} ${person.group}`
.toLowerCase()
.includes(query.toLowerCase()),
);
},
[failure],
);
return (
<div className="w-full max-w-xl space-y-5">
<div>
<p className="text-xs uppercase tracking-widest text-primary">
People & projects
</p>
<h3 className="mt-2 text-2xl font-semibold tracking-tight">
Find the right collaborator.
</h3>
<p className="mt-2 text-sm text-muted-foreground">
Search “al” for Design or “en” for Engineering. Results include roles
and availability.
</p>
</div>
<div className="min-h-80 rounded-2xl border bg-card p-5">
<AsyncAutocomplete
label="Invite a teammate"
value={value}
onValueChange={setValue}
loadOptions={load}
placeholder="Search the team directory"
/>
<div className="mt-6 rounded-lg border border-dashed p-3 text-xs text-muted-foreground">
{value
? `${value.label} is ready to be added. No invitation has been sent.`
: "A thoughtful team starts with the right people."}
</div>
</div>
<Button
variant="outline"
size="sm"
aria-pressed={failure}
onClick={() => setFailure(!failure)}
>
{failure ? "Restore directory" : "Simulate search failure"}
</Button>
<p className="text-xs text-muted-foreground">
The local demo adapter delays results and supports abort. Connect your
search API through loadOptions.
</p>
</div>
);
}Usage
Use this controlled input to select a record from asynchronous results. Supply an AutocompleteOption or null, onValueChange, and a stable loadOptions(query, signal) callback. Requests are debounced and aborted when the query changes, the input closes, or the component unmounts. Results from adapters that ignore abort are still discarded. Selection changes only after choosing a result or pressing Clear; leaving uncommitted text restores the controlled selection. Result values are deduplicated. The example uses a delayed local directory; the Users page uses the active data provider. A real API adapter must enforce authorization server-side.
import {
AsyncAutocomplete,
type AutocompleteOption,
} from "@/components/ui/async-autocomplete";Shipped as editable source in src/components/ui/async-autocomplete.tsx in both apps. Copy the implementation below when moving it to another project. Copy the complete example from Source code for all imports and state. Add "use client" when using hooks or event handlers. See installation and theming for project setup.
Component source
The complete implementation is included in both apps. It uses the React, Radix, Lucide, and shared-core dependencies already installed in Apex. Keep your project’s semantic theme tokens when reusing it.
src/components/ui/async-autocomplete.tsx
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { Search, LoaderCircle, X } from "lucide-react";
import { Input } from "@dashboardpack/core/components/ui/input";
import { Label } from "@dashboardpack/core/components/ui/label";
import { cn } from "@dashboardpack/core/lib/utils";
export interface AutocompleteOption {
value: string;
label: string;
description?: string;
group?: string;
disabled?: boolean;
}
export interface AsyncAutocompleteProps {
label: string;
loadOptions: (
query: string,
signal: AbortSignal,
) => Promise<AutocompleteOption[]>;
value: AutocompleteOption | null;
onValueChange: (option: AutocompleteOption | null) => void;
placeholder?: string;
minLength?: number;
debounceMs?: number;
disabled?: boolean;
className?: string;
}
export function AsyncAutocomplete({
label,
loadOptions,
value,
onValueChange,
placeholder = "Search…",
minLength = 2,
debounceMs = 250,
disabled,
className,
}: AsyncAutocompleteProps) {
const id = useId();
const input = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState(value?.label ?? "");
const [editing, setEditing] = useState(false);
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1);
const [retry, setRetry] = useState(0);
const [result, setResult] = useState<{
query: string;
options: AutocompleteOption[];
status: "ready" | "loading" | "error";
}>({ query: "", options: [], status: "ready" });
const search = query.trim();
const eligible = search.length >= Math.max(0, minLength);
const options =
result.query === search && result.status === "ready" ? result.options : [];
const status = result.query === search ? result.status : "loading";
const displayed = editing ? query : (value?.label ?? "");
useEffect(() => {
if (!open || !editing || !eligible || disabled) return;
const controller = new AbortController();
const timer = setTimeout(
async () => {
setResult({ query: search, options: [], status: "loading" });
try {
const data = await loadOptions(search, controller.signal);
if (!controller.signal.aborted) {
const seen = new Set<string>();
setResult({
query: search,
options: data.filter((item) => {
if (seen.has(item.value)) return false;
seen.add(item.value);
return true;
}),
status: "ready",
});
setActive(-1);
}
} catch {
if (!controller.signal.aborted)
setResult({ query: search, options: [], status: "error" });
}
},
Math.max(0, debounceMs),
);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [
search,
eligible,
open,
editing,
disabled,
loadOptions,
debounceMs,
retry,
]);
const choose = (option: AutocompleteOption) => {
if (option.disabled) return;
onValueChange(option);
setEditing(false);
setOpen(false);
setActive(-1);
};
return (
<div
className={cn("relative min-w-0 space-y-2", className)}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
setOpen(false);
setEditing(false);
setActive(-1);
}
}}
>
<Label htmlFor={id}>{label}</Label>
<div className="relative">
<Search
aria-hidden
className="pointer-events-none absolute start-3 top-3 size-4 text-muted-foreground"
/>
<Input
ref={input}
id={id}
role="combobox"
autoComplete="off"
aria-autocomplete="list"
aria-expanded={open && !disabled}
aria-controls={`${id}-list`}
aria-describedby={`${id}-status`}
aria-activedescendant={
open && options[active] ? `${id}-option-${active}` : undefined
}
placeholder={placeholder}
disabled={disabled}
value={displayed}
className="ps-9 pe-10"
onFocus={() => {
setQuery(value?.label ?? "");
setOpen(true);
}}
onChange={(event) => {
setQuery(event.target.value);
setEditing(true);
setActive(-1);
setOpen(true);
}}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing) return;
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
setEditing(false);
setActive(-1);
}
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
setOpen(true);
const enabled = options
.map((item, index) => (item.disabled ? -1 : index))
.filter((index) => index >= 0);
if (!enabled.length) return;
const currentIndex = enabled.indexOf(active);
const next =
currentIndex < 0
? event.key === "ArrowDown"
? 0
: enabled.length - 1
: (currentIndex +
(event.key === "ArrowDown" ? 1 : -1) +
enabled.length) %
enabled.length;
setActive(enabled[next]);
document
.getElementById(`${id}-option-${enabled[next]}`)
?.scrollIntoView({ block: "nearest" });
}
if (event.key === "Enter" && open && options[active]) {
event.preventDefault();
choose(options[active]);
}
}}
/>
{value && !disabled && (
<button
type="button"
aria-label={`Clear ${label}`}
className="absolute end-1 top-1 rounded-md p-2 text-muted-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => {
onValueChange(null);
setQuery("");
setEditing(true);
setOpen(true);
setActive(-1);
input.current?.focus();
}}
>
<X className="size-4" />
</button>
)}
</div>
<p
id={`${id}-status`}
role="status"
className="text-xs text-muted-foreground"
>
{open && editing && eligible
? status === "loading"
? "Searching…"
: status === "error"
? "Search failed. Try again."
: `${options.length} results available.`
: value
? `Selected: ${value.label}`
: `Type at least ${Math.max(0, minLength)} characters to search.`}
</p>
{open && !disabled && (
<div className="absolute inset-x-0 top-full z-30 mt-1 overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-lg">
<ul
id={`${id}-list`}
role="listbox"
aria-label={`${label} results`}
aria-busy={editing && eligible && status === "loading"}
className="max-h-64 overflow-auto p-1"
>
{editing &&
eligible &&
options.map((option, index) => (
<li
key={option.value}
role="option"
id={`${id}-option-${index}`}
aria-selected={value?.value === option.value}
aria-disabled={option.disabled}
className={cn(
"cursor-pointer rounded-lg px-3 py-2 text-sm outline-none",
active === index && "bg-accent text-accent-foreground",
option.disabled && "cursor-not-allowed opacity-50",
)}
onPointerMove={() => {
if (!option.disabled) setActive(index);
}}
onMouseDown={(event) => event.preventDefault()}
onClick={() => choose(option)}
>
{option.group &&
(index === 0 ||
options[index - 1].group !== option.group) && (
<span className="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{option.group}
</span>
)}
<span className="block font-medium">{option.label}</span>
{option.description && (
<span className="block text-xs text-muted-foreground">
{option.description}
</span>
)}
</li>
))}
</ul>
{!editing || !eligible ? (
<p className="px-4 pb-3 text-sm text-muted-foreground">
Search people, projects, or records.
</p>
) : status === "loading" ? (
<p className="flex items-center gap-2 px-4 pb-3 text-sm text-muted-foreground">
<LoaderCircle className="size-4 animate-spin motion-reduce:animate-none" />
Looking for matches
</p>
) : status === "error" ? (
<div className="space-y-2 px-4 pb-3 text-sm">
<p>Could not load results.</p>
<button
type="button"
className="rounded text-primary underline focus-visible:ring-2 focus-visible:ring-ring"
onMouseDown={(event) => event.preventDefault()}
onClick={() => setRetry((count) => count + 1)}
>
Retry search
</button>
</div>
) : (
!options.length && (
<p className="px-4 pb-3 text-sm text-muted-foreground">
No matches. Try a different search.
</p>
)
)}
</div>
)}
</div>
);
}API reference
Common props and defaults. Slot-specific props are named below; native elements also accept their standard HTML attributes. The component TypeScript definitions are the complete type reference.
| Prop | Type / Default | Description |
|---|---|---|
| label | stringDefault: required | Persistent visible input label. |
| loadOptions | (query, signal) => Promise<AutocompleteOption[]>Default: required | Stable async loader. Return unique values, labels, and optional descriptions, groups, and disabled state. Honor the abort signal where supported. |
| value / onValueChange | AutocompleteOption | null / callbackDefault: required | Parent-owned committed selection. Null clears it. |
| minLength / debounceMs | number / numberDefault: 2 / 250 | Minimum trimmed search length and delay before requesting results. |
| placeholder / disabled / className | string / boolean / stringDefault: Search… / false / — | Input hint, disabled state, and container styling. |