eo-n/ui

Autocomplete

Provides an input field with dynamic suggestions as you type.

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>
  );
}
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 &quot;{value}&quot;.
        </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>
  );
}