AutoComplete
Free-text input with inline filtered suggestions — type-ahead search with a keyboard-safe dropdown, async loading state, and a disabled-option affordance.
Web preview — components render natively on iOS & Android
import { AutoComplete } from "@/components/ui/autocomplete";
const CITIES = [
{ label: "New York, NY", value: "nyc" },
{ label: "Los Angeles, CA", value: "la" },
{ label: "Chicago, IL", value: "chi" },
];
export function MyScreen() {
return (
<AutoComplete
options={CITIES}
placeholder="Start typing a city..."
onSelect={(option) => console.log(option.value)}
/>
);
}Not the same as Combobox. Combobox opens a modal to pick from a fixed list. AutoComplete is a real text field — the suggestion list renders inline, below the input, and pushes the rest of the layout down rather than floating over it (React Native has no reliable viewport-relative overlay without a portal). Keep it out of tight-height rows.
Installation#
npx @aniui/cli add autocompleteUsage#
app/search.tsx
import { AutoComplete } from "@/components/ui/autocomplete";
const CITIES = [
{ label: "New York, NY", value: "nyc" },
{ label: "Los Angeles, CA", value: "la" },
{ label: "Chicago, IL", value: "chi" },
];
export function MyScreen() {
return (
<AutoComplete
options={CITIES}
placeholder="Start typing a city..."
onSelect={(option) => console.log(option.value)}
/>
);
}Custom filtering#
Override filterFn for prefix matching, fuzzy search, or anything else — and raise minCharsToTrigger so the dropdown only opens once there's enough input to filter meaningfully.
Custom filter
<AutoComplete
options={users}
filterFn={(option, query) =>
option.label.toLowerCase().startsWith(query.toLowerCase())
}
minCharsToTrigger={2}
/>Async suggestions#
Set loading while a server request is in flight — the dropdown swaps its list for a loading row instead of showing stale or empty results.
Web preview — components render natively on iOS & Android
const [options, setOptions] = useState<AutoCompleteOption[]>([]);
const [loading, setLoading] = useState(false);
<AutoComplete
options={options}
loading={loading}
onChangeText={async (text) => {
setLoading(true);
setOptions(await searchUsers(text));
setLoading(false);
}}
filterFn={() => true} // results already come pre-filtered from the server
/>Props#
PropTypeDefault
optionsAutoCompleteOption[]-{ label, value, disabled? }[]
valuestring-Controlled text — pair with onChangeText.
onChangeText(text: string) => void-onSelect(option: AutoCompleteOption) => void-filterFn(option, query) => booleancase-insensitive substring matchminCharsToTriggernumber1loadingbooleanfalseemptyTextstring"No results"maxVisibleOptionsnumber5Caps the dropdown's scroll height.
size"sm" | "md" | "lg""md"variant"default" | "ghost""default"classNamestring-Accessibility#
- Each suggestion and the clear button set
accessibilityRole="button"and meet the 48dp minimum touch target. - Disabled options expose
accessibilityState={ disabled: true }and ignore presses. - Selecting a suggestion calls
Keyboard.dismiss()so the dropdown and keyboard close together.
See also: Input · Search Bar
Source#
components/ui/autocomplete.tsx
import React, { useState } from "react";
import { View, TextInput, Pressable, Text, ScrollView, Keyboard, useColorScheme } from "react-native";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react-native";
import { useThemeColors } from "@/components/ui/theme-provider";
import { cn } from "@/lib/utils";
// Matches input.tsx's size scale so Input, SearchBar, and AutoComplete read as one family.
const autoCompleteVariants = cva("flex-row items-center rounded-md border py-2", {
variants: {
variant: {
default: "border-input bg-background",
ghost: "border-transparent bg-transparent",
},
size: {
sm: "min-h-9 px-3",
md: "min-h-12 px-4",
lg: "min-h-14 px-5",
},
},
defaultVariants: { variant: "default", size: "md" },
});
const fontSizes = { sm: 14, md: 16, lg: 18 } as const;
export interface AutoCompleteOption {
label: string;
value: string;
disabled?: boolean;
}
export interface AutoCompleteProps
extends Omit<React.ComponentPropsWithoutRef<typeof TextInput>, "value" | "onChangeText">,
VariantProps<typeof autoCompleteVariants> {
className?: string;
options: AutoCompleteOption[];
value?: string;
onChangeText?: (text: string) => void;
onSelect?: (option: AutoCompleteOption) => void;
filterFn?: (option: AutoCompleteOption, query: string) => boolean;
minCharsToTrigger?: number;
loading?: boolean;
emptyText?: string;
maxVisibleOptions?: number;
}
const defaultFilter = (option: AutoCompleteOption, query: string) =>
option.label.toLowerCase().includes(query.toLowerCase());
// Suggestions render as a normal View below the input, not position: "absolute" —
// RN has no reliable viewport-relative overlay without a portal, so this pushes
// sibling content down instead; keep AutoComplete out of tight-height rows.
export function AutoComplete({
variant, size = "md", className, options, value, onChangeText, onSelect,
filterFn = defaultFilter, minCharsToTrigger = 1, loading, emptyText = "No results",
maxVisibleOptions = 5, style, ...props
}: AutoCompleteProps) {
const [internal, setInternal] = useState("");
const [open, setOpen] = useState(false);
const dark = useColorScheme() === "dark";
const colors = useThemeColors();
const caret = colors.foreground;
const resolvedSize = size ?? "md";
const text = value ?? internal;
const setText = (t: string) => {
if (value === undefined) setInternal(t);
onChangeText?.(t);
setOpen(t.length >= minCharsToTrigger);
};
const handleSelect = (option: AutoCompleteOption) => {
if (option.disabled) return;
onSelect?.(option);
if (value === undefined) setInternal(option.label);
setOpen(false);
Keyboard.dismiss();
};
const showDropdown = open && text.length >= minCharsToTrigger;
const filtered = showDropdown ? options.filter((o) => filterFn(o, text)) : [];
return (
<View>
<View className={cn(autoCompleteVariants({ variant, size }), className)}>
<TextInput
value={text}
onChangeText={setText}
onFocus={() => setOpen(text.length >= minCharsToTrigger)}
className="flex-1 self-stretch p-0 text-foreground placeholder:text-muted-foreground"
style={[{ fontSize: fontSizes[resolvedSize] }, style]}
textAlignVertical="center"
placeholderTextColor={colors.mutedForeground}
keyboardAppearance={dark ? "dark" : "light"}
selectionColor={caret}
cursorColor={caret}
{...props}
/>
{!!text && (
<Pressable
onPress={() => { setText(""); setOpen(false); }}
className="ms-2 min-h-8 min-w-8 items-center justify-center"
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
accessible
accessibilityRole="button"
accessibilityLabel="Clear"
>
<X size={16} color={colors.mutedForeground} />
</Pressable>
)}
</View>
{showDropdown && (
<View className="mt-1 rounded-md border border-border bg-card shadow-lg overflow-hidden">
<ScrollView
style={{ maxHeight: maxVisibleOptions * 48 }}
keyboardShouldPersistTaps="handled"
bounces={false}
>
{loading ? (
<View className="h-12 items-center justify-center"><Text className="text-sm text-muted-foreground">Loading…</Text></View>
) : filtered.length === 0 ? (
<View className="h-12 items-center justify-center"><Text className="text-sm text-muted-foreground">{emptyText}</Text></View>
) : (
filtered.map((option) => (
<Pressable
key={option.value}
onPress={() => handleSelect(option)}
disabled={option.disabled}
className={cn("min-h-12 min-w-12 justify-center px-4", option.disabled && "opacity-40")}
accessible
accessibilityRole="button"
accessibilityState={{ disabled: option.disabled }}
>
<Text className="text-base text-foreground" numberOfLines={1}>{option.label}</Text>
</Pressable>
))
)}
</ScrollView>
</View>
)}
</View>
);
}

