Searchable select with modal option list, live filtering, clearable search, checkmark on the selected option and keyboard-safe layout
components/ui/combobox/combobox.tsx@lumioui/core@latest@lumioui/ui@latestreact-native-reanimated@^4.0.0react-native-safe-area-context@^4.12.0expo-haptics@*| options | ComboboxOption[] | |
| value? | string | Controlled value. Pair with `onValueChange`; omit for uncontrolled. |
| defaultValue? | string | Uncontrolled initial value. Ignored when `value` is provided. |
| onValueChange? | (value: string) => void | Selection callback — optional so uncontrolled usage needs no wiring. |
| open? | boolean | Controlled open state. Pair with `onOpenChange`; omit for uncontrolled. |
| onOpenChange? | (open: boolean) => void | |
| disabled? | boolean | Disables the trigger — dims it and blocks opening the sheet. |
| placeholder? | string | |
| searchPlaceholder? | string | |
| title? | string | Optional sheet heading. Rendered only when provided. |
| className? | string |
Searchable select with modal option list, live filtering, clearable search, checkmark on the selected option and keyboard-safe layout
import { Combobox } from "@/components/ui/combobox/combobox";
export function Example() {
return (
<Combobox />
);
} import * as React from "react";
import { forwardRef, useMemo, useState } from "react";
import { FlatList, KeyboardAvoidingView, Modal, Platform, Pressable, Text, TextInput, View, type ViewProps, } from "react-native";
import Animated, { runOnJS, useAnimatedStyle, useSharedValue, withSpring, withTiming } from "react-native-reanimated";
import { cn, spring, timing, useHaptic, useReducedMotion, usePressAnimation, AnimatedPressable, StaggeredItem } from "@lumioui/core";
import { Icon, type IconName } from "@lumioui/ui";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export interface ComboboxOption {
label: string;
value: string;
/** Leading icon in the option row. */
icon?: IconName;
/** Secondary text under the label. */
subtitle?: string;
}
/** Option row — extracted so each row owns a usePressAnimation hook. */
function OptionRow({
item,
isSelected,
onPick,
}: {
item: ComboboxOption;
isSelected: boolean;
onPick: (value: string) => void;
}) {
const press = usePressAnimation({ pressedScale: 0.98, pressedOpacity: 0.7 });
return (
<AnimatedPressable
onPress={() => onPick(item.value)}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
style={press.animatedStyle}
className={cn(
"min-h-11 flex-row items-center justify-between rounded-xl px-3 py-3",
isSelected && "bg-primary/10"
)}
accessibilityRole="button"
accessibilityState={{ selected: isSelected }}
// RNW drops accessibilityState — mirror for the web option rows.
aria-selected={isSelected}
accessibilityLabel={item.label}
>
<View className="flex-1 flex-row items-center gap-3">
{item.icon ? (
<View className="h-8 w-8 items-center justify-center rounded-lg bg-muted">
<Icon name={item.icon} size="sm" color="foreground" />
</View>
) : null}
<View className="gap-0.5">
<Text
className={cn(
"text-base",
isSelected ? "font-semibold text-primary" : "text-card-foreground"
)}
>
{item.label}
</Text>
{item.subtitle ? (
<Text className="text-sm text-muted-foreground" numberOfLines={1}>{item.subtitle}</Text>
) : null}
</View>
</View>
{isSelected ? (
<Icon name="check" size="sm" color="primary" strokeWidth={3} />
) : null}
</AnimatedPressable>
);
}
export interface ComboboxProps extends Omit<ViewProps, "className"> {
options: ComboboxOption[];
/** Controlled value. Pair with `onValueChange`; omit for uncontrolled. */
value?: string;
/** Uncontrolled initial value. Ignored when `value` is provided. */
defaultValue?: string;
/** Selection callback — optional so uncontrolled usage needs no wiring. */
onValueChange?: (value: string) => void;
/** Controlled open state. Pair with `onOpenChange`; omit for uncontrolled. */
open?: boolean;
onOpenChange?: (open: boolean) => void;
/** Disables the trigger — dims it and blocks opening the sheet. */
disabled?: boolean;
placeholder?: string;
searchPlaceholder?: string;
/** Optional sheet heading. Rendered only when provided. */
title?: string;
className?: string;
}
/**
* Combobox — PRO. Searchable select: a trigger opens a modal with a search
* field and filtered FlatList, checkmark on the selected option.
* Expo Go compatible.
*/
export const Combobox = forwardRef<View, ComboboxProps>(function Combobox(
{
options,
value,
defaultValue,
onValueChange,
open: openProp,
onOpenChange,
disabled = false,
placeholder = "Select…",
searchPlaceholder = "Search…",
title,
className,
...props
},
ref
) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const reduce = useReducedMotion();
const triggerPress = usePressAnimation({ pressedScale: 0.98 });
const clearPress = usePressAnimation({ pressedScale: 0.9 });
// Open state is controlled (`open` + `onOpenChange`) or uncontrolled —
// resolved during render like `value`/`internal` below.
const [internalOpen, setInternalOpen] = useState(false);
const open = openProp ?? internalOpen;
const setOpen = (next: boolean) => {
if (openProp === undefined) setInternalOpen(next);
onOpenChange?.(next);
};
const [query, setQuery] = useState("");
// Controlled (`value`) or uncontrolled (`defaultValue` + internal state) —
// resolved during render, never synced in a passive effect.
const [internal, setInternal] = useState<string | undefined>(defaultValue);
const activeValue = value ?? internal;
// Keeps the Modal mounted while the exit plays — `visible={open}` would
// unmount instantly and the slide-down never gets a frame on screen.
const [rendered, setRendered] = useState(false);
const opacity = useSharedValue(0);
// Sheet travel — the 480pt park offset approximates a full sheet height.
const sheetY = useSharedValue(480);
React.useEffect(() => {
if (open) {
setRendered(true);
opacity.value = reduce ? 1 : withTiming(1, timing.fast);
sheetY.value = reduce ? 0 : withSpring(0, spring.surface);
} else {
setQuery("");
if (reduce) {
opacity.value = 0;
sheetY.value = 480;
setRendered(false);
} else {
// Exit faster than entrance: timing.fast slide+fade vs the
// spring.surface rise.
opacity.value = withTiming(0, timing.fast);
sheetY.value = withTiming(480, timing.fast, (finished) => {
if (finished) runOnJS(setRendered)(false);
});
}
}
}, [open, opacity, sheetY, reduce]);
const backdropStyle = useAnimatedStyle(() => ({ opacity: opacity.value }));
const sheetStyle = useAnimatedStyle(() => ({
transform: [{ translateY: sheetY.value }],
}));
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return options;
return options.filter((o) => o.label.toLowerCase().includes(q));
}, [options, query]);
const selected = options.find((o) => o.value === activeValue);
const close = () => {
haptics.light();
setOpen(false);
};
const pick = (v: string) => {
haptics.selection();
if (value === undefined) setInternal(v);
onValueChange?.(v);
setOpen(false);
};
return (
<View ref={ref} className={className} {...props}>
<AnimatedPressable
onPress={() => {
haptics.light();
setOpen(true);
}}
onPressIn={triggerPress.onPressIn}
onPressOut={triggerPress.onPressOut}
disabled={disabled}
style={triggerPress.animatedStyle}
className={cn(
"h-12 flex-row items-center justify-between rounded-xl border border-input bg-background px-3",
disabled && "opacity-50"
)}
accessibilityRole="combobox"
accessibilityState={{ expanded: open, disabled }}
// RNW drops accessibilityState — the combobox role must report it.
aria-expanded={open}
aria-disabled={disabled || undefined}
accessibilityLabel={selected?.label ?? placeholder}
accessibilityHint="Opens a searchable list of options"
>
<Text
className={cn(
"text-base",
selected ? "text-foreground" : "text-muted-foreground"
)}
numberOfLines={1}
>
{selected?.label ?? placeholder}
</Text>
<Icon name="chevron-down" size="sm" color="mutedForeground" />
</AnimatedPressable>
<Modal
visible={rendered}
transparent
animationType="none"
onRequestClose={() => setOpen(false)}
statusBarTranslucent
>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
className="flex-1 justify-end"
style={{ width: "100%", height: "100%" }}
>
<Animated.View style={[{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }, backdropStyle]} className="bg-overlay">
<Pressable
style={{ width: "100%", height: "100%" }}
onPress={close}
accessibilityLabel="Close picker"
accessibilityRole="button"
/>
</Animated.View>
<Animated.View
// Phone-width sheet — matches paywall-sheet/date-range-picker;
// full-bleed reads wrong on tablets and wide docs canvases.
className="w-full max-w-[480px] self-center max-h-[70%] rounded-t-2xl bg-card p-5"
style={[sheetStyle, { paddingBottom: Math.max(40, insets.bottom + 16) }]}
accessibilityViewIsModal
accessibilityRole="alert"
accessibilityLabel={title ?? searchPlaceholder}
>
<View className="items-center pb-1" accessible={false}>
<View className="h-1.5 w-10 rounded-full bg-muted" />
</View>
{title ? (
<Text className="pb-2 text-center text-base font-semibold leading-tight text-card-foreground">
{title}
</Text>
) : null}
<View className="relative">
<TextInput
value={query}
onChangeText={setQuery}
placeholder={searchPlaceholder}
autoFocus
className="h-12 rounded-xl border border-input bg-background px-3 text-base text-foreground placeholder:text-muted-foreground"
style={query.length > 0 ? { paddingRight: 40 } : undefined}
accessibilityLabel={searchPlaceholder}
/>
{query.length > 0 ? (
<AnimatedPressable
onPress={() => setQuery("")}
onPressIn={clearPress.onPressIn}
onPressOut={clearPress.onPressOut}
style={clearPress.animatedStyle}
hitSlop={8}
className="absolute right-1 top-0 h-full items-center justify-center px-2"
accessibilityRole="button"
accessibilityLabel="Clear search"
>
<Icon name="x" size="sm" color="mutedForeground" />
</AnimatedPressable>
) : null}
</View>
<FlatList
data={filtered}
keyExtractor={(item) => item.value}
keyboardShouldPersistTaps="handled"
className="mt-3"
ListEmptyComponent={
<Text className="py-8 text-center text-sm text-muted-foreground">
{query.trim().length > 0 ? `No results for "${query.trim()}"` : "No results"}
</Text>
}
renderItem={({ item, index }) => (
// Cascade caps at index 8 — deeper rows share the last
// delay so long lists don't pile up seconds of latency.
<StaggeredItem index={Math.min(index, 8)}>
<OptionRow
item={item}
isSelected={item.value === activeValue}
onPick={pick}
/>
</StaggeredItem>
)}
/>
</Animated.View>
</KeyboardAvoidingView>
</Modal>
</View>
);
});
Combobox.displayName = "Combobox";