Autocomplete
Autocomplete is a text input that filters a list of options as the user types.
pnpm dlx dreamy add autocompleteBasic usage of Autocomplete. Items are passed as an array of { value, label } objects. The dropdown filters automatically as the user types.
const fruits = [
{ value: "strawberry", label: "Strawberry" },
{ value: "banana", label: "Banana" },
{ value: "orange", label: "Orange" },
{ value: "cherry", label: "Cherry" },
{ value: "mango", label: "Mango" },
];
<Autocomplete.Root items={fruits}>
<Autocomplete.Input placeholder="Search a fruit..." />
<Autocomplete.Content />
</Autocomplete.Root>Use popoverProps on Autocomplete.Root to pass behavior props to the internal Popover . Keep placement options on the root positioning prop.
Autocomplete uses the shared Portal stack at the dropdown layer (1000). Inside a Dreamy UI overlay, its scope nests automatically and adds 1000 to the parent base. Keep the portal enabled—no manual layer or z-index is needed. For a native or third-party overlay, target its non-scrolling root with popoverProps={{ portalProps: { containerRef } }}.
<Autocomplete.Root
items={fruits}
popoverProps={{ lazyBehavior: "unmount" }}
>
<Autocomplete.Input placeholder="Search a fruit..." />
<Autocomplete.Content />
</Autocomplete.Root>Autocomplete comes with 6 different sizes.
{["sm", "md", "lg", "xl", "2xl"].map((size) => (
<Autocomplete.Root key={size} size={size} items={fruits}>
<Autocomplete.Input placeholder={`Search (${size})...`} />
<Autocomplete.Content />
</Autocomplete.Root>
))}The input trigger can use any Input variant: outline, filled, flushed, or filledOutline.
<Autocomplete.Root triggerVariant="outline" items={fruits}>
<Autocomplete.Input placeholder="Outline trigger..." />
<Autocomplete.Content />
</Autocomplete.Root>
<Autocomplete.Root triggerVariant="filled" items={fruits}>
<Autocomplete.Input placeholder="Filled trigger..." />
<Autocomplete.Content />
</Autocomplete.Root>
<Autocomplete.Root triggerVariant="flushed" items={fruits}>
<Autocomplete.Input placeholder="Flushed trigger..." />
<Autocomplete.Content />
</Autocomplete.Root>
<Autocomplete.Root triggerVariant="filledOutline" items={fruits}>
<Autocomplete.Input placeholder="FilledOutline trigger..." />
<Autocomplete.Content />
</Autocomplete.Root>The dropdown list can be plain (padded, rounded items) or stretched (edge-to-edge items).
<Autocomplete.Root variant="plain" items={fruits}>
<Autocomplete.Input placeholder="Plain variant..." />
<Autocomplete.Content />
</Autocomplete.Root>
<Autocomplete.Root variant="stretched" items={fruits}>
<Autocomplete.Input placeholder="Stretched variant..." />
<Autocomplete.Content />
</Autocomplete.Root>Control the selected value externally with value and onChangeValue.
export function ControlledAutocomplete() {
const [value, setValue] = useState<string | null>(null);
return (
<VStack align="start" gap={2}>
<Text>Selected: {value ?? "none"}</Text>
<Autocomplete.Root
width="xs"
items={fruits}
value={value}
onChangeValue={setValue}
>
<Autocomplete.Input placeholder="Search a fruit..." />
<Autocomplete.Content />
</Autocomplete.Root>
</VStack>
);
}Pass a filterFn prop to override the default case-insensitive substring matching. The function receives each item and the current query string; return true to include the item.
<Autocomplete.Root
items={fruits}
filterFn={(item, query) =>
item.label.toLowerCase().startsWith(query.toLowerCase())
}
>
<Autocomplete.Input placeholder="Starts-with filter..." />
<Autocomplete.Content />
</Autocomplete.Root>Use the renderItem prop on Autocomplete.Content to fully customise how each item is displayed. The render function receives the filtered AutocompleteItem and must return a React node. Use Autocomplete.Item with the matching value so selection still works correctly.
<Autocomplete.Root items={fruits}>
<Autocomplete.Input placeholder="Search a fruit..." />
<Autocomplete.Content
renderItem={(item) => (
<Autocomplete.Item key={item.value} value={item.value}>
<HStack gap={2}>
<LuCherry />
<Text>{item.label}</Text>
</HStack>
</Autocomplete.Item>
)}
/>
</Autocomplete.Root>Sometimes you want to do interactive search bars with easy autocomplete. You'll need input value to eg. download train stations the user is wanting to select. This is a fake demo, it doesn't fetch real station names, just shows how it could be done.
import type { AutocompleteProps } from "components/ui/autocomplete";
import ky from "ky";
import { useState } from "react";
import { Autocomplete } from "@/ui";
import type { Station, StationResponse } from "@/utils/types";
interface StationAutocompleteProps extends Partial<AutocompleteProps> {
value: string;
setValue: (value: string) => void;
}
export function StationAutocomplete({
value,
setValue,
...props
}: StationAutocompleteProps) {
const [inputValue, setInputValue] = useState("");
const [items, setItems] = useState<Station[]>([]);
useDebouncedEffect(
async () => {
if (!inputValue) {
return;
}
const data = await ky
.post("/api/stations", {
body: new URLSearchParams({
term: inputValue
})
})
.json<StationResponse[]>();
setItems(data.map((station) => ({ value: station.value, label: station.name })));
},
[inputValue],
200
);
return (
<Autocomplete.Root
filterFn={(item, query) => true} // add this to disable default filtering...
items={items}
onChangeValue={setValue}
value={value}
{...props}
>
<Autocomplete.Input
onChangeValue={setInputValue}
placeholder="Enter station name..."
value={inputValue}
/>
<Autocomplete.Content />
</Autocomplete.Root>
);
}
function useDebouncedEffect(effect: () => void, dependencies: any[], delay: number) {
useEffect(() => {
const timer = setTimeout(() => {
effect();
}, delay);
return () => {
clearTimeout(timer);
};
}, [...dependencies]);
}Pass an icon prop to Autocomplete.Input to show a leading icon inside the input.
<Autocomplete.Root items={fruits}>
<Autocomplete.Input placeholder="Search a fruit..." icon={<LuSearch />} />
<Autocomplete.Content />
</Autocomplete.Root>By default, a clear button appears when a value is selected. Set isClearable={false} to hide it.
<Autocomplete.Root items={fruits} isClearable={false}>
<Autocomplete.Input placeholder="Search a fruit..." />
<Autocomplete.Content />
</Autocomplete.Root>Customise the message shown when no items match the query with the noResultsText prop on Autocomplete.Content.
<Autocomplete.Root items={fruits}>
<Autocomplete.Input placeholder="Search a fruit..." />
<Autocomplete.Content noResultsText="Nothing here..." />
</Autocomplete.Root>Fetch items when the autocomplete is opened using the onOpen prop. Use noResultsContent on Autocomplete.Content to show a spinner while loading.
export function AsyncAutocomplete() {
const [isLoading, setIsLoading] = useState(true);
const [items, setItems] = useState<AutocompleteItem[]>([]);
function fetchItems() {
if (items.length > 0) return;
fetch("/api/fake-select-data")
.then((res) => res.json())
.then((data: string[]) =>
setItems(data.map((s) => ({ value: s, label: s.charAt(0).toUpperCase() + s.slice(1) })))
)
.finally(() => setIsLoading(false));
}
return (
<Autocomplete.Root items={items} onOpen={fetchItems}>
<Autocomplete.Input placeholder="Search a fruit..." />
<Autocomplete.Content
noResultsContent={isLoading ? <Spinner color="primary" py={4} /> : undefined}
/>
</Autocomplete.Root>
);
}Read the input text with onChangeValue on Autocomplete.Input, then fetch matching items as the user types. Debounce requests so you do not hit the API on every keystroke. Pass filterFn={() => true} when the server already returns filtered results.
export function AsyncSearchAutocomplete() {
const [value, setValue] = useState("");
const [items, setItems] = useState<AutocompleteItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (!value) {
setItems([]);
setIsLoading(false);
return;
}
setIsLoading(true);
const timeoutId = setTimeout(() => {
fetch("/api/fake-select-data")
.then((res) => res.json())
.then((data: string[]) => {
setItems(
data
.filter((item) => item.toLowerCase().includes(value.toLowerCase()))
.map((item) => ({
value: item,
label: item.charAt(0).toUpperCase() + item.slice(1)
}))
);
})
.finally(() => setIsLoading(false));
}, 300);
return () => clearTimeout(timeoutId);
}, [value]);
return (
<Autocomplete.Root items={items} filterFn={() => true}>
<Autocomplete.Input
placeholder="Search fruits..."
onChangeValue={setValue}
/>
<Autocomplete.Content
noResultsContent={
isLoading ? (
<Spinner color="primary" py={4} />
) : value ? undefined : (
<Text color="fg.medium" px={3} py={2}>
Type to search...
</Text>
)
}
noResultsText="No fruits found"
/>
</Autocomplete.Root>
);
}For large lists (100+ items), use Autocomplete.VirtualContent instead of Autocomplete.Content. It uses windowed rendering — only visible items are in the DOM — which significantly improves performance and reduces memory usage.
const manyItems = Array.from({ length: 1000 }, (_, i) => ({
value: `item-${i}`,
label: `Item ${i + 1}`,
}));
<Autocomplete.Root items={manyItems}>
<Autocomplete.Input placeholder="Search items..." />
<Autocomplete.VirtualContent />
</Autocomplete.Root>Props
estimatedItemHeight— Estimated height of each item in pixels. For different autocomplete sizes:xs:26,sm:28,md:32,lg:40. Default:32overscan— Number of items to render outside the visible area. Higher values reduce flickering during fast scrolling. Default:5maxHeight— Maximum height of the virtualized list in pixels. Default:300
<Autocomplete.Root items={manyItems}>
<Autocomplete.Input placeholder="Search items..." />
<Autocomplete.VirtualContent
maxHeight={400}
estimatedItemHeight={36}
overscan={10}
/>
</Autocomplete.Root>You can customise the background color of the selected item with the selectedItemBackgroundScheme prop.
primary
success
warning
error
none
{["primary", "success", "warning", "error", "none"].map((scheme) => (
<Autocomplete.Root
key={scheme}
defaultValue="strawberry"
selectedItemBackgroundScheme={scheme}
items={fruits}
>
<Autocomplete.Input placeholder="Search a fruit..." />
<Autocomplete.Content />
</Autocomplete.Root>
))}