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 casessecondary- 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.
Location Search
"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.
@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:
:hoveror[data-hovered="true"]on trigger - Focus:
:focus-visibleor[data-focus-visible="true"]on trigger - Disabled:
:disabledor[data-disabled="true"]on autocomplete - Open:
[data-open="true"]on indicator
API Reference
Autocomplete
| Prop | Type | Default | Description |
|---|---|---|---|
placeholder | string | 'Select an item' | Temporary text that occupies the autocomplete when it is empty |
selectionMode | "single" | "multiple" | "single" | Whether single or multiple selection is enabled |
allowsEmptyCollection | boolean | false | Whether the autocomplete allows an empty collection. When true, the autocomplete can function even with no items. |
isOpen | boolean | - | Sets the open state of the popover (controlled) |
defaultOpen | boolean | - | Sets the default open state of the popover (uncontrolled) |
onOpenChange | (isOpen: boolean) => void | - | Handler called when the open state changes |
disabledKeys | Iterable<Key> | - | Keys of disabled items |
isDisabled | boolean | - | Whether the autocomplete is disabled |
value | Key | Key[] | null | - | Current value (controlled) |
defaultValue | Key | Key[] | null | - | Default value (uncontrolled) |
onChange | (value: Key | Key[] | null) => void | - | Handler called when the value changes |
isRequired | boolean | - | Whether user input is required |
isInvalid | boolean | - | Whether the autocomplete value is invalid |
name | string | - | The name of the input, used when submitting an HTML form |
fullWidth | boolean | false | Whether 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. |
className | string | - | Additional CSS classes |
children | ReactNode | RenderFunction | - | Autocomplete content or render function |
Autocomplete.Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Additional CSS classes |
children | ReactNode | RenderFunction | - | Trigger content or render function |
Autocomplete.Value
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Additional CSS classes |
children | ReactNode | RenderFunction | - | Value content or render function |
Autocomplete.Indicator
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Additional CSS classes |
children | ReactNode | - | Custom indicator content |
Autocomplete.ClearButton
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Additional CSS classes |
onClick | (e: MouseEvent) => void | - | Handler called when button is clicked |
ref | RefObject<HTMLButtonElement> | - | Ref to the clear button element |
Autocomplete.Popover
| Prop | Type | Default | Description |
|---|---|---|---|
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 |
className | string | - | Additional CSS classes |
children | ReactNode | - | Content children |
Autocomplete.Filter
| Prop | Type | Default | Description |
|---|---|---|---|
filter | (text: string, input: string) => boolean | - | Custom filter function |
inputValue | string | - | Controlled input value |
onInputChange | (value: string) => void | - | Handler called when input value changes |
children | ReactNode | - | 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:
| Option | Type | Default | Description |
|---|---|---|---|
sensitivity | "base" | "accent" | "case" | "variant" | "base" | Locale sensitivity for matching |
Returns:
| Function | Type | Description |
|---|---|---|
contains | (string: string, substring: string) => boolean | Returns whether a string contains a given substring |
startsWith | (string: string, substring: string) => boolean | Returns whether a string starts with a given substring |
endsWith | (string: string, substring: string) => boolean | Returns whether a string ends with a given substring |
Render Props
When using render functions with Autocomplete.Value, these values are provided:
| Prop | Type | Description |
|---|---|---|
defaultChildren | ReactNode | The default rendered value. In multiple mode this is a copy of each selected item's children joined with locale-aware separators |
isPlaceholder | boolean | Whether the value is a placeholder |
state | SelectState | The state of the autocomplete. Use state.selectedItems for the selected collection nodes |
selectedItems | (T | null)[] | The values of the currently selected items |
selectedText | string | The 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.