AutoComplete
freeAutoComplete wraps an Input with a floating dropdown that filters a list of options as the user types.
import { useState } from 'react';
import AutoComplete from '@/components/composites/AutoComplete';
const countries = [
'Australia', 'Belgium', 'Brazil', 'Canada', 'China',
'Denmark', 'Egypt', 'Finland', 'France', 'Germany',
'India', 'Indonesia', 'Italy', 'Japan', 'Mexico',
'Netherlands', 'New Zealand', 'Norway', 'Poland', 'Portugal',
'Singapore', 'South Korea', 'Spain', 'Sweden', 'Switzerland',
'Turkey', 'United Kingdom', 'United States',
];
export default function UsageDemo() {
const [value, setValue] = useState('');
return (
<div>
<AutoComplete
data={countries}
optionKey={(item) => item}
value={value}
onInputChange={setValue}
onOptionSelected={(item) => setValue(item)}
renderOption={(item) => (
<span className="text-sm">{item}</span>
)}
placeholder="Search countries"
className="w-full"
/>
</div>
);
}Installation
Add this component with the NateUI CLI.
npx nateui@latest add AutoCompleteExamples
Custom Render
Use renderOption to display rich content inside each dropdown item. This
example shows an avatar, name, and email for each person.
import { useState } from 'react';
import AutoComplete from '@/components/composites/AutoComplete';
import Avatar from '@/components/ui/Avatar';
type Person = {
name: string;
email: string;
avatar: string;
};
const people: Person[] = [
{ name: 'Aria Patel', email: 'aria@acme.io', avatar: '/img/avatars/thumb-1.jpg' },
{ name: 'Diego Ramos', email: 'diego@acme.io', avatar: '/img/avatars/thumb-2.jpg' },
{ name: 'Mei Lin', email: 'mei@acme.io', avatar: '/img/avatars/thumb-3.jpg' },
{ name: 'Noah Becker', email: 'noah@acme.io', avatar: '/img/avatars/thumb-4.jpg' },
{ name: 'Priya Nair', email: 'priya@acme.io', avatar: '/img/avatars/thumb-5.jpg' },
{ name: 'Lucas Ferreira', email: 'lucas@acme.io', avatar: '/img/avatars/thumb-6.jpg' },
{ name: 'Hana Sato', email: 'hana@acme.io', avatar: '/img/avatars/thumb-7.jpg' },
{ name: 'Owen Clarke', email: 'owen@acme.io', avatar: '/img/avatars/thumb-8.jpg' },
];
export default function CustomRenderDemo() {
const [value, setValue] = useState('');
return (
<div>
<AutoComplete
data={people}
optionKey={(p) => p.name}
value={value}
onInputChange={setValue}
onOptionSelected={(p) => setValue(p.name)}
renderOption={(p) => (
<div className="flex items-center gap-2">
<Avatar shape="round" src={p.avatar} size="sm" />
<div className="min-w-0">
<div className="text-sm font-medium">{p.name}</div>
<div className="text-xs text-muted-foreground truncate">
{p.email}
</div>
</div>
</div>
)}
placeholder="Search people"
className="w-full"
/>
</div>
);
}Controlled
value, onInputChange, and onOptionSelected give full external control
over the input text and the selected option. The component never writes
internal state back to the input.
import { useState } from 'react';
import AutoComplete from '@/components/composites/AutoComplete';
import Tag from '@/components/ui/Tag';
const frameworks = [
'Next.js', 'Remix', 'Astro', 'SvelteKit', 'SolidStart',
'Nuxt', 'Gatsby', 'Vite', 'TanStack Start', 'Qwik City',
];
export default function ControlledDemo() {
const [value, setValue] = useState('');
const [selected, setSelected] = useState<string | null>(null);
return (
<div className="space-y-4">
<AutoComplete
data={frameworks}
optionKey={(f) => f}
value={value}
onInputChange={setValue}
onOptionSelected={(f) => {
setValue(f);
setSelected(f);
}}
renderOption={(f) => (
<span className="text-sm">{f}</span>
)}
placeholder="Pick a framework"
className="w-full"
/>
{selected && (
<div className="text-sm text-muted-foreground">
Selected:{' '}
<Tag>{selected}</Tag>
</div>
)}
</div>
);
}Size
The input size is inherited from the size prop, which follows the standard
ControlSize scale.
import { useState } from 'react';
import AutoComplete from '@/components/composites/AutoComplete';
const colors = [
'Crimson', 'Indigo', 'Teal', 'Amber', 'Slate',
'Rose', 'Emerald', 'Violet', 'Sky', 'Orange',
];
export default function SizeDemo() {
const [sm, setSm] = useState('');
const [md, setMd] = useState('');
const [lg, setLg] = useState('');
return (
<div className="space-y-4">
<AutoComplete
data={colors}
optionKey={(c) => c}
value={sm}
onInputChange={setSm}
onOptionSelected={(c) => setSm(c)}
renderOption={(c) => <span className="text-sm">{c}</span>}
placeholder="Small"
size="sm"
className="w-full"
/>
<AutoComplete
data={colors}
optionKey={(c) => c}
value={md}
onInputChange={setMd}
onOptionSelected={(c) => setMd(c)}
renderOption={(c) => <span className="text-sm">{c}</span>}
placeholder="Medium (default)"
className="w-full"
/>
<AutoComplete
data={colors}
optionKey={(c) => c}
value={lg}
onInputChange={setLg}
onOptionSelected={(c) => setLg(c)}
renderOption={(c) => <span className="text-sm">{c}</span>}
placeholder="Large"
size="lg"
className="w-full"
/>
</div>
);
}Disabled
The disabled state prevents input and blocks the dropdown from opening.
import AutoComplete from '@/components/composites/AutoComplete';
const items = ['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry'];
export default function DisabledDemo() {
return (
<div>
<AutoComplete
data={items}
optionKey={(i) => i}
value="Apple"
onInputChange={() => {}}
onOptionSelected={() => {}}
renderOption={(i) => <span className="text-sm">{i}</span>}
placeholder="Disabled"
disabled
className="w-full"
/>
</div>
);
}Async
Simulate a remote search with a loading state. The remote function owns the filtering, so the component renders the returned results directly.
import { useEffect, useRef, useState } from 'react';
import AutoComplete from '@/components/composites/AutoComplete';
import Spinner from '@/components/ui/Spinner';
type Product = {
name: string;
sku: string;
category: string;
};
const productIndex: Product[] = [
{ name: 'Wireless Mouse', sku: 'ACC-1024', category: 'Peripherals' },
{ name: 'Mechanical Keyboard', sku: 'ACC-2048', category: 'Peripherals' },
{ name: 'USB-C Hub', sku: 'DOC-3100', category: 'Docking' },
{ name: 'Monitor Stand', sku: 'DSK-4400', category: 'Workspace' },
{ name: 'Desk Lamp', sku: 'LIT-1810', category: 'Workspace' },
{ name: 'Webcam HD', sku: 'CAM-2200', category: 'Video' },
{ name: 'Noise Cancelling Headphones', sku: 'AUD-5100', category: 'Audio' },
{ name: 'Laptop Sleeve', sku: 'BAG-1280', category: 'Accessories' },
{ name: 'External SSD', sku: 'DRV-9000', category: 'Storage' },
{ name: 'Graphics Tablet', sku: 'CRT-7200', category: 'Creative' },
{ name: 'Cable Management Kit', sku: 'CAB-1180', category: 'Workspace' },
{ name: 'Power Strip', sku: 'PWR-6600', category: 'Power' },
{ name: 'Ergonomic Chair', sku: 'CHR-7320', category: 'Furniture' },
{ name: 'Footrest', sku: 'FTS-2060', category: 'Furniture' },
{ name: 'Blue Light Glasses', sku: 'EYE-3300', category: 'Accessories' },
{ name: 'Microphone', sku: 'AUD-4200', category: 'Audio' },
];
function searchProducts(query: string): Promise<Product[]> {
return new Promise((resolve) => {
setTimeout(() => {
const normalizedQuery = query.trim().toLowerCase();
const results = productIndex.filter((product) =>
[product.name, product.sku, product.category].some((field) =>
field.toLowerCase().includes(normalizedQuery)
)
);
resolve(results.slice(0, 6));
}, 500);
});
}
export default function AsyncDemo() {
const [value, setValue] = useState('');
const [results, setResults] = useState<Product[]>([]);
const [loading, setLoading] = useState(false);
const requestIdRef = useRef(0);
useEffect(() => {
const query = value.trim();
const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId;
if (query.length < 2) {
setResults([]);
setLoading(false);
return;
}
setResults([]);
setLoading(true);
searchProducts(query).then((nextResults) => {
if (requestIdRef.current !== requestId) {
return;
}
setResults(nextResults);
setLoading(false);
});
}, [value]);
return (
<div>
<AutoComplete
data={results}
optionKey={(product) => product.name}
filterOptions={false}
value={value}
onInputChange={setValue}
onOptionSelected={(product) => setValue(product.name)}
renderOption={(product) => product.name}
placeholder="Search products by name or SKU"
className="w-full"
suffix={
<Spinner
className={
loading ? 'opacity-100' : 'opacity-0'
}
/>
}
/>
</div>
);
}API
| Prop | Description | Type | Default |
|---|---|---|---|
data | Array of options to filter and display. | T[] | — |
optionKey | Function that extracts the display string from an option. Used for filtering and setting the input value on selection. | (obj: T) => string | — |
filterOptions | Whether to apply the built-in text filter before rendering data. Disable this when data is already filtered by a remote search. | boolean | true |
value | Controlled input value. | string | — |
onInputChange | Callback when the input text changes. | (value: string) => void | — |
onOptionSelected | Callback when an option is selected (click or Enter). | (option: T) => void | — |
renderOption | Custom render function for each dropdown item. | (option: T) => ReactNode | — |
size | Input size. | 'sm' | 'md' | 'lg' | 'md' |
disabled | Prevents input and blocks the dropdown. | boolean | false |
placeholder | Input placeholder text. | string | — |
prefix | Content rendered before the input text. | string | ReactNode | — |
suffix | Content rendered after the input text. | string | ReactNode | — |
invalid | Marks the input as invalid (form integration). | boolean | false |
className | Additional classes on the input element. | string | — |
Types
type AutoCompleteProps<T> = Omit<InputProps, 'onChange'> & {
data: Array<T>;
optionKey: (obj: T) => string;
filterOptions?: boolean;
value: string;
onInputChange: (value: string) => void;
onOptionSelected: (option: T) => void;
renderOption: (option: T) => ReactNode;
};