import { XIcon } from "lucide-react";
import {
Autocomplete,
AutocompleteClear,
AutocompleteContent,
AutocompleteEmpty,
AutocompleteInput,
AutocompleteItem,
AutocompleteList,
} from "@/components/ui/autocomplete";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface Tag {
id: string;
value: string;
}
const tags: Tag[] = [
{ id: "t1", value: "feature" },
{ id: "t2", value: "fix" },
{ id: "t3", value: "bug" },
{ id: "t4", value: "docs" },
{ id: "t5", value: "internal" },
{ id: "t6", value: "mobile" },
{ id: "c-accordion", value: "component: accordion" },
{ id: "c-alert-dialog", value: "component: alert dialog" },
{ id: "c-autocomplete", value: "component: autocomplete" },
{ id: "c-avatar", value: "component: avatar" },
{ id: "c-checkbox", value: "component: checkbox" },
{ id: "c-checkbox-group", value: "component: checkbox group" },
{ id: "c-collapsible", value: "component: collapsible" },
{ id: "c-combobox", value: "component: combobox" },
{ id: "c-context-menu", value: "component: context menu" },
{ id: "c-dialog", value: "component: dialog" },
{ id: "c-field", value: "component: field" },
{ id: "c-fieldset", value: "component: fieldset" },
{ id: "c-filterable-menu", value: "component: filterable menu" },
{ id: "c-form", value: "component: form" },
{ id: "c-input", value: "component: input" },
{ id: "c-menu", value: "component: menu" },
{ id: "c-menubar", value: "component: menubar" },
{ id: "c-meter", value: "component: meter" },
{ id: "c-navigation-menu", value: "component: navigation menu" },
{ id: "c-number-field", value: "component: number field" },
{ id: "c-popover", value: "component: popover" },
{ id: "c-preview-card", value: "component: preview card" },
{ id: "c-progress", value: "component: progress" },
{ id: "c-radio", value: "component: radio" },
{ id: "c-scroll-area", value: "component: scroll area" },
{ id: "c-select", value: "component: select" },
{ id: "c-separator", value: "component: separator" },
{ id: "c-slider", value: "component: slider" },
{ id: "c-switch", value: "component: switch" },
{ id: "c-tabs", value: "component: tabs" },
{ id: "c-toast", value: "component: toast" },
{ id: "c-toggle", value: "component: toggle" },
{ id: "c-toggle-group", value: "component: toggle group" },
{ id: "c-toolbar", value: "component: toolbar" },
{ id: "c-tooltip", value: "component: tooltip" },
];
export function AutocompleteDemo() {
return (
<Autocomplete items={tags} autoHighlight>
<AutocompleteInput
render={(props) => (
<Label className="flex w-full max-w-2xs flex-col gap-2">
Search tags
<div className="relative">
<Input placeholder="e.g. feature" {...props} />
<AutocompleteClear
render={
<Button
size="icon"
variant="ghost"
className="absolute top-[50%] left-[94%] translate-x-[-50%] translate-y-[-50%] [&]:size-5"
>
<XIcon className="size-3.5" />
</Button>
}
/>
</div>
</Label>
)}
/>
<AutocompleteContent>
<AutocompleteEmpty>No tags found.</AutocompleteEmpty>
<AutocompleteList>
{(tag: Tag) => (
<AutocompleteItem key={tag.id} value={tag}>
{tag.value}
</AutocompleteItem>
)}
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>
);
}
Installation
npx shadcn@latest add @eo-n/autocomplete
Usage
Import all parts and piece them together.
import {
Autocomplete,
AutocompleteContent,
AutocompleteEmpty,
AutocompleteInput,
AutocompleteItem,
AutocompleteList,
} from "@components/ui/autocomplete";
interface Fruit {
id: string;
value: string;
}
const fruits = [
{ id: "a", value: "Apple" },
{ id: "b", value: "Banana" },
{ id: "c", value: "Cherry" },
{ id: "d", value: "Durian" },
];
<Autocomplete items={fruits}>
<AutocompleteInput />
<AutocompleteContent>
<AutocompleteEmpty>No fruits found.</AutocompleteEmpty>
<AutocompleteList>
{(fruit: Fruit) => (
<AutocompleteItem key={fruit.id} value={fruit}>
{fruit.value}
</AutocompleteItem>
)}
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>;
Examples
Disabled
import {
Autocomplete,
AutocompleteContent,
AutocompleteEmpty,
AutocompleteInput,
AutocompleteItem,
AutocompleteList,
} from "@/components/ui/autocomplete";
interface Fruit {
id: string;
value: string;
}
const fruits: Fruit[] = [
{ id: "1", value: "Apple" },
{ id: "2", value: "Banana" },
{ id: "3", value: "Orange" },
{ id: "4", value: "Grapes" },
{ id: "5", value: "Mango" },
{ id: "6", value: "Strawberry" },
{ id: "7", value: "Blueberry" },
{ id: "8", value: "Watermelon" },
{ id: "9", value: "Pineapple" },
{ id: "10", value: "Kiwi" },
];
export function AutocompleteDisabled() {
return (
<Autocomplete items={fruits}>
<AutocompleteInput
disabled
placeholder="e.g. Apple"
className="max-w-2xs"
/>
<AutocompleteContent>
<AutocompleteEmpty>No fruits found.</AutocompleteEmpty>
<AutocompleteList>
{(fruit: Fruit) => (
<AutocompleteItem key={fruit.id} value={fruit}>
{fruit.value}
</AutocompleteItem>
)}
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>
);
}
Open On Input Click
import { ChevronDown } from "lucide-react";
import {
Autocomplete,
AutocompleteContent,
AutocompleteEmpty,
AutocompleteIcon,
AutocompleteInput,
AutocompleteItem,
AutocompleteList,
AutocompleteTrigger,
} from "@/components/ui/autocomplete";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface Language {
id: string;
value: string;
}
const languages = [
{ id: "js", value: "JavaScript" },
{ id: "ts", value: "TypeScript" },
{ id: "py", value: "Python" },
{ id: "rb", value: "Ruby" },
{ id: "go", value: "Go" },
{ id: "rs", value: "Rust" },
{ id: "c", value: "C" },
{ id: "cpp", value: "C++" },
{ id: "cs", value: "C#" },
{ id: "java", value: "Java" },
{ id: "kt", value: "Kotlin" },
{ id: "swift", value: "Swift" },
{ id: "php", value: "PHP" },
{ id: "dart", value: "Dart" },
{ id: "scala", value: "Scala" },
{ id: "elixir", value: "Elixir" },
{ id: "clj", value: "Clojure" },
{ id: "erl", value: "Erlang" },
{ id: "haskell", value: "Haskell" },
{ id: "lua", value: "Lua" },
{ id: "perl", value: "Perl" },
{ id: "r", value: "R" },
{ id: "matlab", value: "MATLAB" },
{ id: "fortran", value: "Fortran" },
{ id: "vb", value: "Visual Basic" },
];
export function AccordionDemo() {
return (
<Autocomplete items={languages} mode="both" openOnInputClick>
<AutocompleteInput
render={(props) => (
<Label className="flex w-full max-w-3xs flex-col gap-2">
Search languages
<div className="relative">
<Input placeholder="e.g. Typescript" {...props} />
<AutocompleteTrigger className="absolute top-[50%] left-[93%] translate-x-[-50%] translate-y-[-50%]">
<AutocompleteIcon render={<ChevronDown />} />
</AutocompleteTrigger>
</div>
</Label>
)}
/>
<AutocompleteContent>
<AutocompleteEmpty>No languages found.</AutocompleteEmpty>
<AutocompleteList className="">
{(language: Language) => (
<AutocompleteItem key={language.id} value={language}>
{language.value}
</AutocompleteItem>
)}
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>
);
}
Async Search
import * as React from "react";
import { Loader, XIcon } from "lucide-react";
import {
Autocomplete,
AutocompleteClear,
AutocompleteContent,
AutocompleteInput,
AutocompleteItem,
AutocompleteList,
AutocompleteStatus,
useFilter,
} from "@/components/ui/autocomplete";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface Country {
id: string;
name: string;
region: string;
}
const countries: Country[] = [
{ id: "1", name: "Philippines", region: "Asia" },
{ id: "2", name: "United States", region: "North America" },
{ id: "3", name: "Canada", region: "North America" },
{ id: "4", name: "Japan", region: "Asia" },
{ id: "5", name: "Germany", region: "Europe" },
{ id: "6", name: "France", region: "Europe" },
{ id: "7", name: "Brazil", region: "South America" },
{ id: "8", name: "Australia", region: "Oceania" },
{ id: "9", name: "Egypt", region: "Africa" },
{ id: "10", name: "South Africa", region: "Africa" },
{ id: "11", name: "India", region: "Asia" },
{ id: "12", name: "China", region: "Asia" },
{ id: "13", name: "South Korea", region: "Asia" },
{ id: "14", name: "Italy", region: "Europe" },
{ id: "15", name: "Spain", region: "Europe" },
{ id: "16", name: "United Kingdom", region: "Europe" },
{ id: "17", name: "Mexico", region: "North America" },
{ id: "18", name: "Argentina", region: "South America" },
{ id: "19", name: "Chile", region: "South America" },
{ id: "20", name: "Nigeria", region: "Africa" },
{ id: "21", name: "Kenya", region: "Africa" },
{ id: "22", name: "Saudi Arabia", region: "Asia" },
{ id: "23", name: "United Arab Emirates", region: "Asia" },
{ id: "24", name: "Turkey", region: "Europe/Asia" },
{ id: "25", name: "Russia", region: "Europe/Asia" },
{ id: "26", name: "Sweden", region: "Europe" },
{ id: "27", name: "Norway", region: "Europe" },
{ id: "28", name: "New Zealand", region: "Oceania" },
{ id: "29", name: "Thailand", region: "Asia" },
{ id: "30", name: "Vietnam", region: "Asia" },
];
export function AutocompleteAsyncSearch() {
const [searchValue, setSearchValue] = React.useState("");
const [isLoading, setIsLoading] = React.useState(false);
const [searchResults, setSearchResults] = React.useState<Country[]>([]);
const [error, setError] = React.useState<string | null>(null);
const { contains } = useFilter({ sensitivity: "base" });
React.useEffect(() => {
if (!searchValue) {
setSearchResults([]);
setIsLoading(false);
return undefined;
}
setIsLoading(true);
setError(null);
let ignore = false;
async function fetchCountries() {
try {
const results = await searchCountries(searchValue, contains);
if (!ignore) {
setSearchResults(results);
}
} catch (err) {
if (!ignore) {
setError("Failed to fetch countries. Please try again.");
setSearchResults([]);
}
} finally {
if (!ignore) {
setIsLoading(false);
}
}
}
const timeoutId = setTimeout(fetchCountries, 300);
return () => {
clearTimeout(timeoutId);
ignore = true;
};
}, [searchValue, contains]);
let status: React.ReactNode = `${searchResults.length} result${searchResults.length === 1 ? "" : "s"} found`;
if (isLoading) {
status = (
<React.Fragment>
<Loader className="animate-spin" />
Searching...
</React.Fragment>
);
} else if (error) {
status = error;
} else if (searchResults.length === 0 && searchValue) {
status = `Country or region "${searchValue}" does not exist in the list of countries`;
}
const shouldRenderPopup = searchValue !== "";
return (
<Autocomplete
items={searchResults}
value={searchValue}
onValueChange={setSearchValue}
itemToStringValue={(item) => item.name}
filter={null}
>
<AutocompleteInput
render={(props) => (
<Label className="flex w-full max-w-2xs flex-col gap-2">
Search country or region
<div className="relative">
<Input placeholder="e.g. Japan or Europe" {...props} />
<AutocompleteClear
render={
<Button
size="icon"
variant="ghost"
className="absolute top-[50%] left-[94%] translate-x-[-50%] translate-y-[-50%] [&]:size-5"
>
<XIcon className="size-3.5" />
</Button>
}
/>
</div>
</Label>
)}
/>
{shouldRenderPopup && (
<AutocompleteContent>
<AutocompleteStatus>{status}</AutocompleteStatus>
<AutocompleteList>
{(country: Country) => (
<AutocompleteItem key={country.id} value={country}>
<div className="leading-5 font-medium">
{country.name}
<span className="text-primary-foreground bg-primary ml-2 inline-block rounded-full px-1.5 py-[1px] text-xs leading-4 font-normal">
{country.region}
</span>
</div>
</AutocompleteItem>
)}
</AutocompleteList>
</AutocompleteContent>
)}
</Autocomplete>
);
}
// Simulates an async API call to search countries by name or region
async function searchCountries(
query: string,
filter: (item: string, query: string) => boolean
): Promise<Country[]> {
// Simulate network delay (random between 100–600ms)
await new Promise((resolve) =>
setTimeout(resolve, Math.random() * 500 + 100)
);
// Simulate occasional network errors (1% chance or if query === "will_error")
if (Math.random() < 0.01 || query === "will_error") {
throw new Error("Network error");
}
// Return filtered results:
// match either by country name or region against the query
return countries.filter(
(country) => filter(country.name, query) || filter(country.region, query)
);
}
Limit Results
import * as React from "react";
import { XIcon } from "lucide-react";
import {
Autocomplete,
AutocompleteClear,
AutocompleteContent,
AutocompleteEmpty,
AutocompleteInput,
AutocompleteItem,
AutocompleteList,
AutocompleteStatus,
useFilter,
} from "@/components/ui/autocomplete";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface Tag {
id: string;
value: string;
}
const tags: Tag[] = [
{ id: "t1", value: "feature" },
{ id: "t2", value: "fix" },
{ id: "t3", value: "bug" },
{ id: "t4", value: "docs" },
{ id: "t5", value: "internal" },
{ id: "t6", value: "mobile" },
{ id: "c-accordion", value: "component: accordion" },
{ id: "c-alert-dialog", value: "component: alert dialog" },
{ id: "c-autocomplete", value: "component: autocomplete" },
{ id: "c-avatar", value: "component: avatar" },
{ id: "c-checkbox", value: "component: checkbox" },
{ id: "c-checkbox-group", value: "component: checkbox group" },
{ id: "c-collapsible", value: "component: collapsible" },
{ id: "c-combobox", value: "component: combobox" },
{ id: "c-context-menu", value: "component: context menu" },
{ id: "c-dialog", value: "component: dialog" },
{ id: "c-field", value: "component: field" },
{ id: "c-fieldset", value: "component: fieldset" },
{ id: "c-filterable-menu", value: "component: filterable menu" },
{ id: "c-form", value: "component: form" },
{ id: "c-input", value: "component: input" },
{ id: "c-menu", value: "component: menu" },
{ id: "c-menubar", value: "component: menubar" },
{ id: "c-meter", value: "component: meter" },
{ id: "c-navigation-menu", value: "component: navigation menu" },
{ id: "c-number-field", value: "component: number field" },
{ id: "c-popover", value: "component: popover" },
{ id: "c-preview-card", value: "component: preview card" },
{ id: "c-progress", value: "component: progress" },
{ id: "c-radio", value: "component: radio" },
{ id: "c-scroll-area", value: "component: scroll area" },
{ id: "c-select", value: "component: select" },
{ id: "c-separator", value: "component: separator" },
{ id: "c-slider", value: "component: slider" },
{ id: "c-switch", value: "component: switch" },
{ id: "c-tabs", value: "component: tabs" },
{ id: "c-toast", value: "component: toast" },
{ id: "c-toggle", value: "component: toggle" },
{ id: "c-toggle-group", value: "component: toggle group" },
{ id: "c-toolbar", value: "component: toolbar" },
{ id: "c-tooltip", value: "component: tooltip" },
];
const limit = 8;
export function AutocompleteLimitResult() {
const [value, setValue] = React.useState("");
const { contains } = useFilter({ sensitivity: "base" });
const totalMatches = React.useMemo(() => {
const trimmed = value.trim();
if (!trimmed) {
return tags.length;
}
return tags.filter((t) => contains(t.value, trimmed)).length;
}, [value, contains]);
const moreCount = Math.max(0, totalMatches - limit);
return (
<Autocomplete
items={tags}
value={value}
onValueChange={setValue}
limit={limit}
>
<AutocompleteInput
render={(props) => (
<Label className="flex w-full max-w-2xs flex-col gap-2">
Search tags
<div className="relative">
<Input placeholder="e.g. feature" {...props} />
<AutocompleteClear
render={
<Button
size="icon"
variant="ghost"
className="absolute top-[50%] left-[94%] translate-x-[-50%] translate-y-[-50%] [&]:size-5"
>
<XIcon className="size-3.5" />
</Button>
}
/>
</div>
</Label>
)}
/>
<AutocompleteContent>
<AutocompleteEmpty>
No results found for "{value}".
</AutocompleteEmpty>
<AutocompleteList>
{(tag: Tag) => (
<AutocompleteItem key={tag.id} value={tag}>
{tag.value}
</AutocompleteItem>
)}
</AutocompleteList>
<AutocompleteStatus>
{moreCount > 0
? `Hiding ${moreCount} results (type a more specific query to narrow results)`
: null}
</AutocompleteStatus>
</AutocompleteContent>
</Autocomplete>
);
}