Autocomplete

Autocomplete is a text input that filters a list of options as the user types.

pnpm dlx dreamy add autocomplete

Basic 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: 32
  • overscan — Number of items to render outside the visible area. Higher values reduce flickering during fast scrolling. Default: 5
  • maxHeight — 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>
))}