Skip to content
Lenso UI

Autocomplete

An autocomplete combines a select with filtering, allowing users to search and select from a list of options

Usage

import { Autocomplete, useFilter } from "@lenso/ui";
"use client";/** HeroUI v3.2.6 adaptation. SPDX-License-Identifier: Apache-2.0 */import { NativeAutocomplete } from "./_native";
export const states = [  { id: "florida", name: "Florida" },  { id: "delaware", name: "Delaware" },  { id: "california", name: "California" },  { id: "texas", name: "Texas" },  { id: "new-york", name: "New York" },  { id: "washington", name: "Washington" },];export default function Default() {  return (    <NativeAutocomplete      items={states}      label="States to Visit"      placeholder="Select states"      multiple      chips      hideClear    />  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Anatomy

import {Autocomplete, Label, Description, SearchField, ListBox} from "@lenso/ui";
export default () => (  <Autocomplete>    <Label />    <Autocomplete.Trigger>      <Autocomplete.Value />      <Autocomplete.ClearButton />      <Autocomplete.Indicator />    </Autocomplete.Trigger>    <Description />    <Autocomplete.Popover>      <Autocomplete.Filter>        <SearchField>          <SearchField.Group>            <SearchField.SearchIcon />            <SearchField.Input />          </SearchField.Group>        </SearchField>        <ListBox>          <ListBox.Item>            <Label />            <ListBox.ItemIndicator />          </ListBox.Item>        </ListBox>      </Autocomplete.Filter>    </Autocomplete.Popover>  </Autocomplete>);

Examples

Variants

The Autocomplete component supports two visual variants:

  • primary (default) - Standard styling with shadow, suitable for most use cases
  • secondary - Lower emphasis variant without shadow, suitable for use in Surface components
"use client";/** HeroUI v3.2.6 adaptation. SPDX-License-Identifier: Apache-2.0 */import * as stylex from "@stylexjs/stylex";import { NativeAutocomplete, styles } from "./_native";const items = [1, 2, 3, 4].map((number) => ({ id: `option${number}`, name: `Option ${number}` }));export function Variants() {  return (    <div {...stylex.props(styles.section)}>      <section {...stylex.props(styles.stack)}>        <h3>Single Select Variants</h3>        <NativeAutocomplete items={items} label="Primary variant" variant="primary" />        <NativeAutocomplete items={items} label="Secondary variant" variant="secondary" />      </section>      <section {...stylex.props(styles.stack)}>        <h3>Multiple Select Variants</h3>        <NativeAutocomplete          items={items}          label="Primary variant"          variant="primary"          placeholder="Select multiple"          multiple          chips        />        <NativeAutocomplete          items={items}          label="Secondary variant"          variant="secondary"          placeholder="Select multiple"          multiple          chips        />      </section>    </div>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Full Width

"use client";/** HeroUI v3.2.6 adaptation. SPDX-License-Identifier: Apache-2.0 */import { Surface } from "@lenso/ui";import { NativeAutocomplete, styles } from "./_native";import { states } from "./default";export function FullWidth() {  return (    <Surface xstyle={[styles.surface, styles.fullSurface]}>      <NativeAutocomplete        items={states}        label="State"        variant="secondary"        fullWidth        searchLabel="Search states"      />    </Surface>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

With Description

"use client";/** HeroUI v3.2.6 adaptation. SPDX-License-Identifier: Apache-2.0 */import { NativeAutocomplete } from "./_native";import { states } from "./default";export function WithDescription() {  return (    <NativeAutocomplete      items={states}      label="State"      searchLabel="Search states"      searchPlaceholder="Search states..."      description="Select your state of residence"    />  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Required

"use client";/** HeroUI v3.2.6 adaptation. SPDX-License-Identifier: Apache-2.0 */import { Button } from "@lenso/ui";import { useState } from "react";import * as stylex from "@stylexjs/stylex";import { NativeAutocomplete, styles, type Option } from "./_native";import { states } from "./default";
const countries = [  { id: "usa", name: "United States" },  { id: "canada", name: "Canada" },  { id: "mexico", name: "Mexico" },  { id: "uk", name: "United Kingdom" },  { id: "france", name: "France" },  { id: "germany", name: "Germany" },];export function Required() {  const [state, setState] = useState<Option | Option[] | null>(null);  const [country, setCountry] = useState<Option | Option[] | null>(null);  const [submitted, setSubmitted] = useState(false);  return (    <form      noValidate      {...stylex.props(styles.stack)}      onSubmit={(event) => {        event.preventDefault();        setSubmitted(true);        const data = new FormData(event.currentTarget);        if (data.get("state") && data.get("country")) {          alert("Form submitted successfully!");          return;        }        const fields =          event.currentTarget.querySelectorAll<HTMLButtonElement>('button[role="combobox"]');        const firstInvalid = fields[data.get("state") ? 1 : 0];        requestAnimationFrame(() => {          if (firstInvalid?.isConnected) firstInvalid.focus();        });      }}    >      <NativeAutocomplete        items={states}        label="State"        name="state"        required        value={state}        onValueChange={setState}        invalid={submitted && !state}        error={submitted && !state ? "Please select a state." : undefined}        searchLabel="Search states"      />      <NativeAutocomplete        items={countries}        label="Country"        name="country"        required        value={country}        onValueChange={setCountry}        invalid={submitted && !country}        error={submitted && !country ? "Please select a country." : undefined}        placeholder="Select a country"        searchLabel="Search countries"      />      <Button type="submit">Submit</Button>    </form>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Disabled

"use client";/** HeroUI v3.2.6 adaptation. SPDX-License-Identifier: Apache-2.0 */import * as stylex from "@stylexjs/stylex";import { NativeAutocomplete, styles } from "./_native";import { states } from "./default";const countries = [  { id: "argentina", name: "Argentina" },  { id: "venezuela", name: "Venezuela" },  { id: "japan", name: "Japan" },  { id: "france", name: "France" },  { id: "italy", name: "Italy" },  { id: "spain", name: "Spain" },];export function Disabled() {  return (    <div {...stylex.props(styles.stack)}>      <NativeAutocomplete        items={states}        label="State"        disabled        defaultValue={states.find((item) => item.id === "california")}      />      <NativeAutocomplete        items={countries}        label="Countries to Visit"        placeholder="Select countries"        disabled        multiple        defaultValue={countries.filter((item) =>          ["argentina", "japan", "france"].includes(item.id),        )}      />    </div>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

With Disabled Options

"use client";/** HeroUI v3.2.6 adaptation. SPDX-License-Identifier: Apache-2.0 */import { NativeAutocomplete } from "./_native";const animals = [  { id: "dog", name: "Dog" },  { id: "cat", name: "Cat", disabled: true },  { id: "bird", name: "Bird" },  { id: "kangaroo", name: "Kangaroo", disabled: true },  { id: "elephant", name: "Elephant" },  { id: "tiger", name: "Tiger" },];export function WithDisabledOptions() {  return (    <NativeAutocomplete      items={animals}      label="Animal"      placeholder="Select an animal"      searchLabel="Search animals"      searchPlaceholder="Search animals..."    />  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Allows Empty Collection

The allowsEmptyCollection prop enables the autocomplete to function even when there are no items in the collection. This is useful for scenarios where the list might be empty initially or when all items are filtered out.

"use client";/** HeroUI v3.2.6 adaptation. SPDX-License-Identifier: Apache-2.0 */import { NativeAutocomplete } from "./_native";export function AllowsEmptyCollection() {  return (    <NativeAutocomplete      items={[]}      label="State"      searchLabel="Search states"      searchPlaceholder="Search states..."    />  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

With Sections

"use client";/** HeroUI v3.2.6 adaptation. SPDX-License-Identifier: Apache-2.0 */import { Autocomplete } from "@lenso/ui";import { useId, useState } from "react";import { NativeAutocomplete } from "./_native";
const groups = [  {    name: "North America",    items: [      { id: "usa", name: "United States" },      { id: "canada", name: "Canada" },      { id: "mexico", name: "Mexico" },    ],  },  {    name: "Europe",    items: [      { id: "uk", name: "United Kingdom" },      { id: "france", name: "France" },      { id: "germany", name: "Germany" },      { id: "spain", name: "Spain" },      { id: "italy", name: "Italy" },    ],  },  {    name: "Asia",    items: [      { id: "japan", name: "Japan" },      { id: "china", name: "China" },      { id: "india", name: "India" },      { id: "south-korea", name: "South Korea" },    ],  },];export function WithSections() {  const id = useId();  const { contains } = Autocomplete.useFilter({ sensitivity: "base" });  const [query, setQuery] = useState("");  const visibleGroups = groups    .map((group) => ({      ...group,      items: group.items.filter((item) => contains(item.name, query)),    }))    .filter((group) => group.items.length);  return (    <NativeAutocomplete      items={groups.flatMap((group) => group.items)}      filteredItems={visibleGroups.flatMap((group) => group.items)}      label="Country"      placeholder="Select a country"      searchLabel="Search countries"      searchPlaceholder="Search countries..."      inputValue={query}      onInputValueChange={setQuery}      list={        <Autocomplete.List>          {visibleGroups.map((group, index) => (            <Autocomplete.Group key={group.name} aria-labelledby={`${id}-${index}`}>              {index > 0 && <Autocomplete.Separator />}              <Autocomplete.GroupLabel id={`${id}-${index}`}>{group.name}</Autocomplete.GroupLabel>              {group.items.map((item) => (                <Autocomplete.Item key={item.id} value={item}>                  {item.name}                  <Autocomplete.ItemIndicator />                </Autocomplete.Item>              ))}            </Autocomplete.Group>          ))}        </Autocomplete.List>      }    />  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Multiple Select

"use client";/** HeroUI v3.2.6 adaptation. SPDX-License-Identifier: Apache-2.0 */import { NativeAutocomplete } from "./_native";import { controlledStates } from "./controlled";export function MultipleSelect() {  return (    <NativeAutocomplete      items={controlledStates}      label="States"      placeholder="Select states"      multiple      chips    />  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

In multiple mode, a bare <Autocomplete.Value /> renders a copy of each selected item's ListBox.Item children in the trigger, joined with locale-aware separators. Text-only items read as plain text, but visual content such as checkboxes or avatars appears once per selected item, and render function children are always called with isSelected: false.

To display selected values as plain text, use the selectedText render prop, which joins the textValue of each selected item:

<Autocomplete.Value>  {({isPlaceholder, defaultChildren, selectedText}) =>    isPlaceholder ? defaultChildren : selectedText  }</Autocomplete.Value>

For richer layouts such as removable tags, render your own content from state.selectedItems as shown in the example above.

Controlled

"use client";/** HeroUI v3.2.6 adaptation. SPDX-License-Identifier: Apache-2.0 */import { useState } from "react";import * as stylex from "@stylexjs/stylex";import { NativeAutocomplete, styles, type Option } from "./_native";export const controlledStates = [  { id: "california", name: "California" },  { id: "texas", name: "Texas" },  { id: "florida", name: "Florida" },  { id: "new-york", name: "New York" },  { id: "illinois", name: "Illinois" },  { id: "pennsylvania", name: "Pennsylvania" },];export function Controlled() {  const [state, setState] = useState<Option | Option[] | null>(controlledStates[0] ?? null);  return (    <div {...stylex.props(styles.stack)}>      <NativeAutocomplete        items={controlledStates}        label="State (controlled)"        placeholder="Select a state"        searchLabel="Search states"        value={state}        onValueChange={setState}      />      <p {...stylex.props(styles.muted)}>        Selected: {state && !Array.isArray(state) ? state.name : "None"}      </p>    </div>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Controlled Multiple

"use client";/** HeroUI v3.2.6 adaptation. SPDX-License-Identifier: Apache-2.0 */import { useState } from "react";import * as stylex from "@stylexjs/stylex";import { NativeAutocomplete, styles, type Option } from "./_native";import { controlledStates } from "./controlled";export function ControlledMultiple() {  const [selected, setSelected] = useState<Option | Option[] | null>(() =>    controlledStates.slice(0, 2),  );  return (    <div {...stylex.props(styles.stack)}>      <NativeAutocomplete        items={controlledStates}        label="States"        placeholder="Select states"        multiple        value={selected}        onValueChange={setSelected}        searchLabel="Search states"      />      <p {...stylex.props(styles.muted)}>        Selected:{" "}        {Array.isArray(selected) && selected.length          ? selected.map((item) => item.id).join(", ")          : "None"}      </p>    </div>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Controlled Open State

"use client";/** HeroUI v3.2.6 adaptation. SPDX-License-Identifier: Apache-2.0 */import { Button } from "@lenso/ui";import { useState } from "react";import * as stylex from "@stylexjs/stylex";import { NativeAutocomplete, styles } from "./_native";import { states } from "./default";export function ControlledOpenState() {  const [open, setOpen] = useState(false);  return (    <div {...stylex.props(styles.stack)}>      <NativeAutocomplete        items={states}        label="State"        searchLabel="Search states"        open={open}        onOpenChange={setOpen}      />      <Button onClick={() => setOpen(!open)}>{open ? "Close" : "Open"} Autocomplete</Button>      <p {...stylex.props(styles.muted)}>Autocomplete is {open ? "open" : "closed"}</p>    </div>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Asynchronous Filtering

"use client";/** HeroUI v3.2.6 adaptation. SPDX-License-Identifier: Apache-2.0 */import { useEffect, useState } from "react";import { NativeAutocomplete, type Option } from "./_native";
interface ResponseData {  results: { name: string }[];}export function AsynchronousFiltering() {  const [query, setQuery] = useState("");  const [result, setResult] = useState<{ query: string; items: Option[]; error?: string } | null>(    null,  );  useEffect(() => {    const controller = new AbortController();    const load = async () => {      try {        const response = await fetch(          `https://swapi.py4e.com/api/people/?search=${encodeURIComponent(query)}`,          { signal: controller.signal },        );        if (!response.ok) throw new Error("Could not load characters");        const data = (await response.json()) as ResponseData;        if (!controller.signal.aborted)          setResult({            query,            items: data.results.map((item) => ({ id: item.name, name: item.name })),          });      } catch (error) {        if (!controller.signal.aborted)          setResult({            query,            items: [],            error: error instanceof Error ? error.message : "Could not load characters",          });      }    };    void load();    return () => controller.abort();  }, [query]);  const loading = result?.query !== query;  return (    <NativeAutocomplete      items={result?.items ?? []}      filteredItems={loading ? [] : (result?.items ?? [])}      label="Search a Star Wars characters"      placeholder="Search..."      searchLabel="Search characters"      searchPlaceholder="Search characters..."      inputValue={query}      onInputValueChange={setQuery}      loading={loading}      emptyText={result?.error || "No results found"}    />  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Custom Indicator

"use client";/** HeroUI v3.2.6 adaptation. SPDX-License-Identifier: Apache-2.0 */import { ChevronsExpandVertical } from "@gravity-ui/icons";import { NativeAutocomplete } from "./_native";import { states } from "./default";export function CustomIndicator() {  return (    <NativeAutocomplete      items={states}      label="State"      searchLabel="Search states"      indicator={<ChevronsExpandVertical width={12} height={12} />}    />  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Custom Value

You can customize the displayed value using render props:

"use client";/** HeroUI v3.2.6 adaptation. SPDX-License-Identifier: Apache-2.0 */import * as stylex from "@stylexjs/stylex";import { NativeAutocomplete, styles } from "./_native";
const currencies = [  { code: "USD", id: "usd", name: "US Dollar", symbol: "$" },  { code: "EUR", id: "eur", name: "Euro", symbol: "€" },  { code: "GBP", id: "gbp", name: "British Pound", symbol: "£" },  { code: "JPY", id: "jpy", name: "Japanese Yen", symbol: "¥" },  { code: "CHF", id: "chf", name: "Swiss Franc", symbol: "₣" },].map((currency) => ({ ...currency, searchText: `${currency.code} ${currency.name}` }));export function CustomValue() {  return (    <NativeAutocomplete      items={currencies}      label="Currency"      placeholder="Select a currency"      hideClear      defaultValue={currencies[0]}      searchLabel="Search currencies"      searchPlaceholder="Search currencies..."      renderItem={(item) => {        const currency = currencies.find((entry) => entry.id === item.id);        return (          <span {...stylex.props(styles.details)}>            <span>{currency?.code}</span>            <span {...stylex.props(styles.muted)}>{item.name}</span>          </span>        );      }}      renderValue={(item) => {        const currency = currencies.find((entry) => entry.id === item.id);        return currency ? (          <span {...stylex.props(styles.row)}>            <strong>{currency.symbol}</strong>            <span>{currency.code}</span>            <span {...stylex.props(styles.muted)}>{currency.name}</span>          </span>        ) : (          item.name        );      }}    />  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

In Surface

When used inside a Surface component, use variant="secondary" to apply the lower emphasis variant suitable for surface backgrounds.

"use client";/** HeroUI v3.2.6 adaptation. SPDX-License-Identifier: Apache-2.0 */import { Surface } from "@lenso/ui";import { NativeAutocomplete, styles } from "./_native";import { states } from "./default";export function OnSurface() {  return (    <Surface xstyle={styles.surface}>      <NativeAutocomplete        items={states}        label="State"        variant="secondary"        fullWidth        searchLabel="Search states"      />    </Surface>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Virtualization

Autocomplete supports virtualization through Virtualizer, enabling efficient rendering of large datasets by displaying only the rows visible within the viewport.

"use client";/** HeroUI v3.2.6 adaptation. SPDX-License-Identifier: Apache-2.0 */import { Autocomplete } from "@lenso/ui";import { useRef, useState } from "react";import * as stylex from "@stylexjs/stylex";import { NativeAutocomplete, OptionContent, type Option } from "./_native";
const firstNames = [  "Emma",  "Liam",  "Olivia",  "Noah",  "Ava",  "James",  "Sophia",  "Oliver",  "Isabella",  "Lucas",  "Mia",  "Ethan",  "Charlotte",  "Mason",  "Amelia",  "Logan",  "Harper",  "Alexander",  "Ella",  "Benjamin",];const lastNames = [  "Smith",  "Johnson",  "Williams",  "Brown",  "Jones",  "Garcia",  "Miller",  "Davis",  "Rodriguez",  "Martinez",  "Anderson",  "Taylor",  "Thomas",  "Jackson",  "White",  "Harris",  "Clark",  "Lewis",  "Robinson",  "Walker",];const allUsers: Option[] = Array.from({ length: 1000 }, (_, index) => {  const first = firstNames[index % firstNames.length] ?? "";  const last = lastNames[Math.floor(index / firstNames.length) % lastNames.length] ?? "";  return {    id: String(index + 1),    name: `${first} ${last}`,    email: `${first.toLowerCase()}.${last.toLowerCase()}@acme.com`,  };});const virtualStyles = stylex.create({  field: { width: 300 },  list: { height: 300, overflowY: "auto", position: "relative", padding: 0 },  row: { height: 50, boxSizing: "border-box" },  spacer: (height: number) => ({ height }),});export function Virtualization() {  const { contains } = Autocomplete.useFilter({ sensitivity: "base" });  const [query, setQuery] = useState("");  const [scrollTop, setScrollTop] = useState(0);  const listRef = useRef<HTMLDivElement>(null);  const filtered = allUsers.filter(    (user) => contains(user.name, query) || contains(user.email ?? "", query),  );  const start = Math.max(0, Math.floor(scrollTop / 50) - 3);  const end = Math.min(filtered.length, start + 14);  const onHighlight = (item: Option | undefined) => {    const index = filtered.findIndex((user) => user.id === item?.id);    const element = listRef.current;    if (index < 0 || !element) return;    const top = index * 50;    if (top < element.scrollTop) element.scrollTop = top;    else if (top + 50 > element.scrollTop + 300) element.scrollTop = top - 250;    setScrollTop(element.scrollTop);  };  return (    <NativeAutocomplete      items={allUsers}      xstyle={virtualStyles.field}      filteredItems={filtered}      virtualized      onItemHighlighted={onHighlight}      label="User"      placeholder="Select a user"      searchLabel="Search users"      searchPlaceholder="Search users..."      inputValue={query}      onInputValueChange={(next) => {        setQuery(next);        setScrollTop(0);        if (listRef.current) listRef.current.scrollTop = 0;      }}      list={        <Autocomplete.List          ref={listRef}          xstyle={virtualStyles.list}          onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}        >          <div aria-hidden="true" {...stylex.props(virtualStyles.spacer(start * 50))} />          {filtered.slice(start, end).map((item, offset) => (            <Autocomplete.Item              key={item.id}              value={item}              index={start + offset}              aria-setsize={filtered.length}              aria-posinset={start + offset + 1}              xstyle={virtualStyles.row}            >              <OptionContent item={item} />              <Autocomplete.ItemIndicator />            </Autocomplete.Item>          ))}          <div            aria-hidden="true"            {...stylex.props(virtualStyles.spacer((filtered.length - end) * 50))}          />        </Autocomplete.List>      }    />  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Advanced Examples

User Selection

"use client";/** HeroUI v3.2.6 adaptation. SPDX-License-Identifier: Apache-2.0 */import * as stylex from "@stylexjs/stylex";import { NativeAutocomplete, OptionAvatar, styles } from "./_native";export const users = [  {    avatarUrl: "https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/blue.jpg",    email: "[email protected]",    fallback: "B",    id: "1",    name: "Bob",  },  {    avatarUrl: "https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/green.jpg",    email: "[email protected]",    fallback: "F",    id: "2",    name: "Fred",  },  {    avatarUrl: "https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/purple.jpg",    email: "[email protected]",    fallback: "M",    id: "3",    name: "Martha",  },  {    avatarUrl: "https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/red.jpg",    email: "[email protected]",    fallback: "J",    id: "4",    name: "John",  },  {    avatarUrl: "https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/orange.jpg",    email: "[email protected]",    fallback: "J",    id: "5",    name: "Jane",  },];export function UserSelection() {  return (    <NativeAutocomplete      items={users}      label="User"      placeholder="Select a user"      searchLabel="Search users"      searchPlaceholder="Search users..."      renderValue={(item) => (        <span {...stylex.props(styles.row)}>          <OptionAvatar item={item} small />          {item.name}        </span>      )}    />  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

User Selection Multiple

"use client";/** HeroUI v3.2.6 adaptation. SPDX-License-Identifier: Apache-2.0 */import { NativeAutocomplete } from "./_native";import { users } from "./user-selection";export function UserSelectionMultiple() {  return (    <NativeAutocomplete      items={users}      label="Users"      placeholder="Select your teammates"      multiple      chips      searchLabel="Search users"      searchPlaceholder="Search users..."    />  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

"use client";/** HeroUI v3.2.6 adaptation. SPDX-License-Identifier: Apache-2.0 */import { Autocomplete } from "@lenso/ui";import { useEffect, useState } from "react";import { NativeAutocomplete } from "./_native";
const cities = [  { country: "USA", name: "New York" },  { country: "USA", name: "Los Angeles" },  { country: "USA", name: "Chicago" },  { country: "UK", name: "London" },  { country: "France", name: "Paris" },  { country: "Japan", name: "Tokyo" },  { country: "Australia", name: "Sydney" },  { country: "Canada", name: "Toronto" },  { country: "Germany", name: "Berlin" },  { country: "Spain", name: "Madrid" },].map((city) => ({ ...city, id: city.name }));
export function LocationSearch() {  const { contains } = Autocomplete.useFilter({ sensitivity: "base" });  const [query, setQuery] = useState("");  const [settledQuery, setSettledQuery] = useState("");  useEffect(() => {    const timeout = setTimeout(() => setSettledQuery(query), 300);    return () => clearTimeout(timeout);  }, [query]);  const loading = query !== settledQuery;  const results = cities.filter((city) => contains(city.name, settledQuery));  return (    <NativeAutocomplete      items={cities}      label="City"      placeholder="Search for a city"      searchLabel="Search cities"      searchPlaceholder="Search cities..."      inputValue={query}      onInputValueChange={setQuery}      filteredItems={loading ? [] : results}      loading={loading}      emptyText="No cities found"    />  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Tag Group Selection

"use client";/** HeroUI v3.2.6 adaptation. SPDX-License-Identifier: Apache-2.0 */import { NativeAutocomplete } from "./_native";const tags = [  { id: "react", name: "React" },  { id: "typescript", name: "TypeScript" },  { id: "javascript", name: "JavaScript" },  { id: "nodejs", name: "Node.js" },  { id: "python", name: "Python" },  { id: "vue", name: "Vue" },  { id: "angular", name: "Angular" },  { id: "nextjs", name: "Next.js" },];export function TagGroupSelection() {  return (    <NativeAutocomplete      items={tags}      label="Tags"      placeholder="Select tags"      multiple      chips      searchLabel="Search tags"      searchPlaceholder="Search tags..."      emptyText="No tags found"    />  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Email Recipients

"use client";/** HeroUI v3.2.6 adaptation. SPDX-License-Identifier: Apache-2.0 */import { NativeAutocomplete } from "./_native";const emails = [  { email: "[email protected]", id: "[email protected]", name: "Alice Johnson" },  { email: "[email protected]", id: "[email protected]", name: "Bob Smith" },  { email: "[email protected]", id: "[email protected]", name: "Charlie Brown" },  { email: "[email protected]", id: "[email protected]", name: "Diana Prince" },  { email: "[email protected]", id: "[email protected]", name: "Eve Wilson" },];export function EmailRecipients() {  return (    <NativeAutocomplete      items={emails}      label="To"      placeholder="Add recipients"      multiple      chips      chipText={(item) => item.email}      searchLabel="Search emails"      searchPlaceholder="Search emails..."      emptyText="No recipients found"    />  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Customization

Tailwind CSS

"use client";/** HeroUI v3.2.6 adaptation. SPDX-License-Identifier: Apache-2.0 */import { NativeAutocomplete } from "./_native";const teammates = [  { id: "sarah", name: "Sarah Chen", role: "Product Design" },  { id: "marcus", name: "Marcus Lee", role: "Engineering" },  { id: "priya", name: "Priya Patel", role: "Data Science" },  { id: "jordan", name: "Jordan Kim", role: "Customer Success" },  { id: "alex", name: "Alex Rivera", role: "Marketing" },];export function CustomStyles() {  return (    <NativeAutocomplete      items={teammates}      label="Assignees"      description="People who will be notified when this task updates."      placeholder="Search teammates..."      searchLabel="Search by name or role"      searchPlaceholder="Search by name or role..."      multiple      chips      custom      emptyText="No teammates found"    />  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Global CSS

To customize the Autocomplete component classes, you can use the @layer components directive.

Learn more.

@layer components {  .autocomplete {    @apply flex flex-col gap-1;  }
  .autocomplete__trigger {    @apply rounded-lg border border-border bg-surface p-2;  }
  .autocomplete__value {    @apply text-current;  }
  .autocomplete__clear-button {    @apply text-muted hover:text-foreground;  }
  .autocomplete__indicator {    @apply text-muted;  }
  .autocomplete__popover {    @apply rounded-lg border border-border bg-surface p-2;  }}

Styling Reference

HeroUI follows the BEM methodology to ensure component variants and states are reusable and easy to customize.

CSS Classes

The Autocomplete component uses these CSS classes (View source styles):

Base Classes [!toc]

  • .autocomplete - Base autocomplete container
  • .autocomplete__trigger - The button that triggers the autocomplete
  • .autocomplete__value - The displayed value or placeholder
  • .autocomplete__clear-button - The clear button that removes the selected value
  • .autocomplete__indicator - The dropdown indicator icon
  • .autocomplete__popover - The popover container
  • .autocomplete__filter - The filter wrapper

Variant Classes [!toc]

  • .autocomplete--primary - Primary variant with shadow (default)
  • .autocomplete--secondary - Secondary variant without shadow, suitable for use in surfaces

State Classes [!toc]

  • .autocomplete[data-invalid="true"] - Invalid state
  • .autocomplete__trigger[data-focus-visible="true"] - Focused trigger state
  • .autocomplete__trigger[data-disabled="true"] - Disabled trigger state
  • .autocomplete__value[data-placeholder="true"] - Placeholder state
  • .autocomplete__clear-button[data-empty="true"] - Clear button hidden when no selection
  • .autocomplete__indicator[data-open="true"] - Open indicator state

Interactive States

The component supports both CSS pseudo-classes and data attributes for flexibility:

  • Hover: :hover or [data-hovered="true"] on trigger
  • Focus: :focus-visible or [data-focus-visible="true"] on trigger
  • Disabled: :disabled or [data-disabled="true"] on autocomplete
  • Open: [data-open="true"] on indicator

API Reference

Autocomplete

PropTypeDefaultDescription
placeholderstring'Select an item'Temporary text that occupies the autocomplete when it is empty
selectionMode"single" | "multiple""single"Whether single or multiple selection is enabled
allowsEmptyCollectionbooleanfalseWhether the autocomplete allows an empty collection. When true, the autocomplete can function even with no items.
isOpenboolean-Sets the open state of the popover (controlled)
defaultOpenboolean-Sets the default open state of the popover (uncontrolled)
onOpenChange(isOpen: boolean) => void-Handler called when the open state changes
disabledKeysIterable<Key>-Keys of disabled items
isDisabledboolean-Whether the autocomplete is disabled
valueKey | Key[] | null-Current value (controlled)
defaultValueKey | Key[] | null-Default value (uncontrolled)
onChange(value: Key | Key[] | null) => void-Handler called when the value changes
isRequiredboolean-Whether user input is required
isInvalidboolean-Whether the autocomplete value is invalid
namestring-The name of the input, used when submitting an HTML form
fullWidthbooleanfalseWhether the autocomplete should take full width of its container
variant"primary" | "secondary""primary"Visual variant of the component. primary is the default style with shadow. secondary is a lower emphasis variant without shadow, suitable for use in surfaces.
classNamestring-Additional CSS classes
childrenReactNode | RenderFunction-Autocomplete content or render function

Autocomplete.Trigger

PropTypeDefaultDescription
classNamestring-Additional CSS classes
childrenReactNode | RenderFunction-Trigger content or render function

Autocomplete.Value

PropTypeDefaultDescription
classNamestring-Additional CSS classes
childrenReactNode | RenderFunction-Value content or render function

Autocomplete.Indicator

PropTypeDefaultDescription
classNamestring-Additional CSS classes
childrenReactNode-Custom indicator content

Autocomplete.ClearButton

PropTypeDefaultDescription
classNamestring-Additional CSS classes
onClick(e: MouseEvent) => void-Handler called when button is clicked
refRefObject<HTMLButtonElement>-Ref to the clear button element

Autocomplete.Popover

PropTypeDefaultDescription
placement"bottom" | "bottom left" | "bottom right" | "bottom start" | "bottom end" | "top" | "top left" | "top right" | "top start" | "top end" | "left" | "left top" | "left bottom" | "start" | "start top" | "start bottom" | "right" | "right top" | "right bottom" | "end" | "end top" | "end bottom""bottom"Placement of the popover relative to the trigger
classNamestring-Additional CSS classes
childrenReactNode-Content children

Autocomplete.Filter

PropTypeDefaultDescription
filter(text: string, input: string) => boolean-Custom filter function
inputValuestring-Controlled input value
onInputChange(value: string) => void-Handler called when input value changes
childrenReactNode-Filter content (SearchField and ListBox)

useFilter Hook

The useFilter hook from React Aria provides filtering functions for autocomplete functionality.

import {useFilter} from "@lenso/ui";
const {contains} = useFilter({sensitivity: "base"});
<Autocomplete.Filter filter={contains}>  <SearchField>...</SearchField>  <ListBox>...</ListBox></Autocomplete.Filter>

Options:

OptionTypeDefaultDescription
sensitivity"base" | "accent" | "case" | "variant""base"Locale sensitivity for matching

Returns:

FunctionTypeDescription
contains(string: string, substring: string) => booleanReturns whether a string contains a given substring
startsWith(string: string, substring: string) => booleanReturns whether a string starts with a given substring
endsWith(string: string, substring: string) => booleanReturns whether a string ends with a given substring

Render Props

When using render functions with Autocomplete.Value, these values are provided:

PropTypeDescription
defaultChildrenReactNodeThe default rendered value. In multiple mode this is a copy of each selected item's children joined with locale-aware separators
isPlaceholderbooleanWhether the value is a placeholder
stateSelectStateThe state of the autocomplete. Use state.selectedItems for the selected collection nodes
selectedItems(T | null)[]The values of the currently selected items
selectedTextstringThe textValue of the selected items joined with locale-aware separators

Accessibility

The Autocomplete component implements the ARIA select pattern with filtering and provides:

  • Full keyboard navigation support
  • Screen reader announcements for selection changes
  • Proper focus management
  • Support for disabled states
  • Search functionality with filtering
  • HTML form integration

For more information, see the React Aria Select documentation.