Autocomplete
Composable collection filter and suggestion coordinator connecting text inputs with menus, lists, tables, and tags.
Autocomplete
An autocomplete enables users to search or filter a collection of items using a text input like SearchField or TextField. It coordinates filtering, virtual focus management, and keyboard navigation across collections such as lists, menus, tables, and tag groups.
Autocomplete vs. ComboBox
Use ComboBox to select one or more values from a predefined popup set. Use Autocomplete to filter an arbitrary collection (in-place or floating) or provide text suggestions and completions.
Interactive Preview
Installation
import {
Autocomplete,
AutocompleteList,
AutocompleteItem,
AutocompleteSection,
AutocompleteEmptyState,
AutocompletePopover,
useFilter,
} from '@moul-dev/ui';Basic Usage
By default, <Autocomplete> provides a styled container and applies a smart case-insensitive contains filter across collection items:
import {
Autocomplete,
AutocompleteList,
AutocompleteItem,
AutocompleteSection,
SearchField,
} from '@moul-dev/ui';
export function Example() {
return (
<Autocomplete>
<SearchField
aria-label="Filter commands"
placeholder="Type to filter actions..."
/>
<AutocompleteList aria-label="Actions">
<AutocompleteSection title="Projects">
<AutocompleteItem
id="new"
description="Create a new workspace component"
shortcut="⌘N"
>
New Component
</AutocompleteItem>
<AutocompleteItem
id="open"
description="Open an existing project folder"
shortcut="⌘O"
>
Open Project
</AutocompleteItem>
</AutocompleteSection>
<AutocompleteSection title="Preferences">
<AutocompleteItem
id="theme"
description="Toggle light or dark color scheme"
shortcut="⌘T"
>
Toggle Theme
</AutocompleteItem>
</AutocompleteSection>
</AutocompleteList>
</Autocomplete>
);
}Floating Dropdown with Popover
Combine <Autocomplete> with <AutocompletePopover> to display filterable suggestions beneath an input upon focus:
import {
Autocomplete,
AutocompletePopover,
AutocompleteList,
AutocompleteItem,
SearchField,
} from '@moul-dev/ui';
import { useState } from 'react';
export function PopoverExample() {
const [isOpen, setIsOpen] = useState(false);
return (
<Autocomplete>
<SearchField
aria-label="Filter programming languages"
placeholder="Search languages..."
onFocus={() => setIsOpen(true)}
/>
<AutocompletePopover
isOpen={isOpen}
onOpenChange={setIsOpen}
isNonModal
>
<AutocompleteList
aria-label="Languages"
selectionMode="single"
onSelectionChange={() => setIsOpen(false)}
variant="borderless"
>
<AutocompleteItem id="ts" description="Typed JavaScript superset">
TypeScript
</AutocompleteItem>
<AutocompleteItem id="js" description="Standard web scripting">
JavaScript
</AutocompleteItem>
<AutocompleteItem id="rust" description="Safe systems programming">
Rust
</AutocompleteItem>
<AutocompleteItem id="go" description="Cloud native concurrency">
Go
</AutocompleteItem>
</AutocompleteList>
</AutocompletePopover>
</Autocomplete>
);
}Creatable with Popover
When filtering options in a dropdown yields no matches, you can turn the empty state into a creation action. By listening to the Return key (Enter) on the input and rendering an interactive creation trigger with <Kbd>↵</Kbd>, users can seamlessly create and select custom options:
import {
Autocomplete,
AutocompletePopover,
AutocompleteList,
AutocompleteItem,
SearchField,
Kbd,
tokens,
} from '@moul-dev/ui';
import { useState } from 'react';
export function CreatablePopoverExample() {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState('');
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [items, setItems] = useState([
{ id: 'react', name: 'React', description: 'Component-based UI library' },
{ id: 'vue', name: 'Vue', description: 'Progressive JavaScript framework' },
{ id: 'svelte', name: 'Svelte', description: 'Cybernetically enhanced web apps' },
{ id: 'solid', name: 'Solid', description: 'Simple and performant reactivity' },
{ id: 'angular', name: 'Angular', description: 'Enterprise frontend platform' },
]);
const trimmedQuery = query.trim();
const matchingItems = items.filter((item) =>
item.name.toLowerCase().includes(trimmedQuery.toLowerCase())
);
const isNoResults = trimmedQuery.length > 0 && matchingItems.length === 0;
const handleCreate = (name: string) => {
const clean = name.trim();
if (!clean) return;
const id = clean.toLowerCase().replace(/\s+/g, '-');
const existing = items.find(
(item) => item.id === id || item.name.toLowerCase() === clean.toLowerCase()
);
if (!existing) {
const newItem = {
id,
name: clean,
description: 'User-created framework',
};
setItems((prev) => [...prev, newItem]);
setSelectedKey(id);
} else {
setSelectedKey(existing.id);
}
setQuery('');
setIsOpen(false);
};
return (
<Autocomplete>
<SearchField
aria-label="Filter or create framework"
placeholder="Search or create framework..."
value={query}
onChange={(val) => {
setQuery(val);
if (!isOpen) setIsOpen(true);
}}
onFocus={() => setIsOpen(true)}
onKeyDown={(e) => {
if (e.key === 'Enter' && isNoResults) {
e.preventDefault();
handleCreate(query);
}
}}
/>
<AutocompletePopover
isOpen={isOpen}
onOpenChange={setIsOpen}
isNonModal
>
<AutocompleteList
aria-label="Frameworks"
selectionMode="single"
selectedKeys={selectedKey ? [selectedKey] : []}
onSelectionChange={(keys) => {
const [first] = Array.from(keys);
if (first) setSelectedKey(String(first));
setIsOpen(false);
}}
variant="borderless"
renderEmptyState={() => (
<div className="p-1">
<button
type="button"
onClick={() => handleCreate(query)}
className="w-full flex items-center justify-between gap-2 px-3 py-2 text-sm rounded cursor-pointer transition-colors text-left hover:bg-[var(--moul-color-bg-subtle,#f4f4f5)] dark:hover:bg-neutral-800"
style={{ color: tokens.colorFg }}
>
<span className="flex items-center gap-2 truncate">
<span
className="w-4 h-4 flex items-center justify-center rounded-full text-xs font-bold shrink-0"
style={{
backgroundColor: tokens.colorPrimary50,
color: tokens.colorPrimary700,
}}
>
+
</span>
<span className="truncate">
Create <strong className="font-semibold">“{trimmedQuery}”</strong>
</span>
</span>
<span className="flex items-center gap-1 shrink-0 text-xs text-neutral-400">
<span className="text-[10px]">Press</span>
<Kbd>↵</Kbd>
</span>
</button>
</div>
)}
>
{items.map((item) => (
<AutocompleteItem
key={item.id}
id={item.id}
description={item.description}
>
{item.name}
</AutocompleteItem>
))}
</AutocompleteList>
</AutocompletePopover>
</Autocomplete>
);
}TagGroup Filtering
<Autocomplete> is not restricted to listboxes — it seamlessly coordinates with collections like <TagGroup>:
import { Autocomplete, SearchField, TagGroup, Tag } from '@moul-dev/ui';
export function TagFilteringExample() {
return (
<Autocomplete>
<SearchField
aria-label="Filter tags"
placeholder="Filter technology tags..."
/>
<TagGroup
aria-label="Technologies"
selectionMode="multiple"
renderEmptyState={() => 'No matching tags found.'}
>
<Tag id="react">React</Tag>
<Tag id="stylex">StyleX</Tag>
<Tag id="typescript">TypeScript</Tag>
<Tag id="waku">Waku</Tag>
<Tag id="vite">Vite</Tag>
</TagGroup>
</Autocomplete>
);
}Asynchronous / Remote Search
For server-side querying or API search, set mode="async" (or pass filter={null}) to bypass client-side filtering while retaining keyboard coordination:
import {
Autocomplete,
AutocompleteList,
AutocompleteItem,
SearchField,
} from '@moul-dev/ui';
import { useState, useEffect } from 'react';
export function AsyncExample() {
const [query, setQuery] = useState('');
const [items, setItems] = useState<{ id: string; name: string }[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
let active = true;
setIsLoading(true);
fetch(`/api/search?q=${encodeURIComponent(query)}`)
.then((res) => res.json())
.then((data) => {
if (active) setItems(data.items);
})
.finally(() => {
if (active) setIsLoading(false);
});
return () => {
active = false;
};
}, [query]);
return (
<Autocomplete
mode="async"
inputValue={query}
onInputChange={setQuery}
>
<SearchField aria-label="Server Search" placeholder="Search database..." />
<AutocompleteList aria-label="Results">
{items.map((item) => (
<AutocompleteItem key={item.id} id={item.id}>
{item.name}
</AutocompleteItem>
))}
</AutocompleteList>
</Autocomplete>
);
}Custom Styling with StyleX Tokens
Every component in the Autocomplete family accepts compile-time StyleX overrides via the style prop. Import tokens from @moul-dev/ui to build custom variants that respond seamlessly to theme variables and density scales:
import {
Autocomplete,
AutocompleteList,
AutocompleteItem,
SearchField,
tokens,
} from '@moul-dev/ui';
import * as stylex from '@stylexjs/stylex';
const styles = stylex.create({
container: {
maxWidth: '460px',
gap: tokens.spacing3,
},
list: {
maxHeight: '260px',
borderRadius: tokens.radiusLg,
borderColor: tokens.colorBorderSubtle,
boxShadow: tokens.shadowMd,
},
customItem: {
borderRadius: tokens.radiusMd,
paddingBlock: tokens.spacing2,
},
});
export function StyledAutocomplete() {
return (
<Autocomplete style={styles.container}>
<SearchField
aria-label="Filter options"
placeholder="Filter custom list..."
/>
<AutocompleteList aria-label="Options" style={styles.list}>
<AutocompleteItem id="1" style={styles.customItem}>
First Option
</AutocompleteItem>
<AutocompleteItem id="2" style={styles.customItem}>
Second Option
</AutocompleteItem>
</AutocompleteList>
</Autocomplete>
);
}Props
Autocomplete
Prop
Type
AutocompleteList
Prop
Type
AutocompleteItem
Prop
Type
AutocompletePopover
Prop
Type