Mobile-native multi-select with chip trigger, bottom-sheet picker, search filtering, and selected-item chips. Spring-animated sheet entrance, haptic selection feedback.
components/ui/multi-select/multi-select.tsx@lumioui/core@latest@lumioui/ui@latestreact-native-reanimated@^4.0.0react-native-safe-area-context@*expo-haptics@*Live preview — the real component, driven by the props below.
import { MultiSelect } from "@/components/ui/multi-select/multi-select";
export function Example() {
return (
<MultiSelect />
);
} import * as React from "react";
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { View, Text, Pressable, Modal, TextInput, ScrollView, type ViewProps, } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
runOnJS,
} from "react-native-reanimated";
import { cn, useHaptic, useReducedMotion, spring, timing, usePressAnimation, AnimatedPressable, StaggeredItem } from "@lumioui/core";
import { Icon, type IconName } from "@lumioui/ui";
import { useSafeAreaInsets } from "react-native-safe-area-context";
/**
* MultiSelect — mobile-native multi-select with a chip trigger, a bottom
* sheet picker, search filtering, and selected-item chips.
* <MultiSelect
* options={[{ value: "apple", label: "Apple" }, { value: "banana", label: "Banana" }]}
* value={selected}
* onValueChange={setSelected}
* placeholder="Pick fruits"
* />
*/
export interface MultiSelectOption {
value: string;
label: string;
icon?: IconName;
disabled?: boolean;
}
/** Selected-value chip — extracted so it can own mount/remove pop and a
* usePressAnimation hook (can't call hooks inside the parent's map). */
function SelectedChip({ opt, onRemove }: { opt: MultiSelectOption; onRemove: (value: string) => void }) {
const press = usePressAnimation({ pressedScale: 0.85 });
const reduce = useReducedMotion();
// Chip pop: springs in on mount, shrinks out before removal commits so
// the add/remove reads as a physical pop, not a list reflow.
const pop = useSharedValue(reduce ? 1 : 0);
useEffect(() => {
pop.value = reduce ? 1 : withSpring(1, spring.bouncy);
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only entrance
}, []);
const popStyle = useAnimatedStyle(() => ({
transform: [{ scale: pop.value }],
opacity: pop.value,
}));
const handleRemove = () => {
if (reduce) {
onRemove(opt.value);
return;
}
pop.value = withTiming(0, timing.fast, (finished) => {
if (finished) runOnJS(onRemove)(opt.value);
});
};
return (
<Animated.View style={popStyle} className="flex-row items-center gap-1 rounded-full bg-primary/10 pl-2.5 pr-1 py-1">
{opt.icon ? <Icon name={opt.icon} size="sm" color="primary" /> : null}
<Text className="text-sm font-medium text-primary">{opt.label}</Text>
{/* 24pt visual + 10pt hitSlop = 44pt touch target. */}
<AnimatedPressable
onPress={handleRemove}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
style={press.animatedStyle}
hitSlop={10}
className="h-6 w-6 items-center justify-center"
accessibilityRole="button"
accessibilityLabel={`Remove ${opt.label}`}
>
<Icon name="x" size="sm" color="primary" />
</AnimatedPressable>
</Animated.View>
);
}
/** Option row in the picker sheet — extracted for per-row press animation
* and a check-pop on toggle. */
function OptionRow({ opt, isSelected, onToggle }: { opt: MultiSelectOption; isSelected: boolean; onToggle: (value: string) => void }) {
const press = usePressAnimation({ pressedScale: 0.98 });
const reduce = useReducedMotion();
// Check pop — the tick springs in with a touch of overshoot on select.
const check = useSharedValue(isSelected ? 1 : 0);
useEffect(() => {
check.value = reduce ? (isSelected ? 1 : 0) : withSpring(isSelected ? 1 : 0, spring.bouncy);
}, [isSelected, check, reduce]);
const checkStyle = useAnimatedStyle(() => ({
transform: [{ scale: check.value }],
opacity: check.value,
}));
return (
<AnimatedPressable
onPress={() => !opt.disabled && onToggle(opt.value)}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
disabled={opt.disabled}
style={press.animatedStyle}
className={cn(
"w-full flex-row items-center gap-3 rounded-xl px-3 py-3.5",
opt.disabled && "opacity-50"
)}
accessibilityRole="checkbox"
accessibilityState={{ checked: isSelected, disabled: opt.disabled }}
// RNW drops accessibilityState — mirror so the checkbox role reports
// its checked/disabled state on web.
aria-checked={isSelected}
aria-disabled={opt.disabled || undefined}
accessibilityLabel={opt.label}
>
{opt.icon ? <Icon name={opt.icon} size="sm" color="mutedForeground" /> : null}
<Text className="flex-1 text-base text-foreground">{opt.label}</Text>
<View className={cn("h-5 w-5 items-center justify-center rounded-md border-2", isSelected ? "border-primary bg-primary" : "border-muted-foreground/30")}>
<Animated.View style={checkStyle}>
<Icon name="check" size="sm" color="primaryForeground" />
</Animated.View>
</View>
</AnimatedPressable>
);
}
export interface MultiSelectProps extends ViewProps {
options: MultiSelectOption[];
/** Controlled selected values. */
value?: string[];
/** Initial selected values (uncontrolled). */
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
/** Placeholder shown when nothing is selected. */
placeholder?: string;
/** Search placeholder. */
searchPlaceholder?: string;
/** Empty state text when no options match. */
emptyText?: string;
disabled?: boolean;
/** Accessible label. */
accessibilityLabel?: string;
className?: string;
}
export const MultiSelect = forwardRef<React.ElementRef<typeof View>, MultiSelectProps>(
function MultiSelect(
{ options, value, defaultValue, onValueChange, placeholder = "Select…", searchPlaceholder = "Search…", emptyText = "No results", disabled, accessibilityLabel = "Multi select", className, ...props },
ref
) {
const haptics = useHaptic();
const reduce = useReducedMotion();
const insets = useSafeAreaInsets();
const triggerPress = usePressAnimation({ pressedScale: 0.98 });
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [internal, setInternal] = useState<string[]>(defaultValue ?? []);
const selected = value ?? internal;
const filtered = useMemo(() => {
if (!query.trim()) return options;
const q = query.toLowerCase();
return options.filter((o) => o.label.toLowerCase().includes(q));
}, [options, query]);
const toggle = useCallback(
(val: string) => {
const next = selected.includes(val) ? selected.filter((v) => v !== val) : [...selected, val];
if (value === undefined) setInternal(next);
onValueChange?.(next);
haptics.selection();
},
[selected, value, onValueChange, haptics]
);
const remove = useCallback(
(val: string) => {
const next = selected.filter((v) => v !== val);
if (value === undefined) setInternal(next);
onValueChange?.(next);
haptics.light();
},
[selected, value, onValueChange, haptics]
);
const selectedLabels = useMemo(
() => selected.map((v) => options.find((o) => o.value === v)).filter(Boolean) as MultiSelectOption[],
[selected, options]
);
// Sheet animation
const sheetY = useSharedValue(0);
const backdropOpacity = useSharedValue(0);
// Keeps the Modal mounted while the exit runs — `visible={open}` would
// unmount before the slide-down plays.
const [rendered, setRendered] = useState(false);
const wasOpen = useRef(false);
useEffect(() => {
if (open && !wasOpen.current) {
setRendered(true);
if (reduce) {
sheetY.value = 0;
backdropOpacity.value = 1;
} else {
sheetY.value = 400;
backdropOpacity.value = 0;
sheetY.value = withSpring(0, spring.surface);
backdropOpacity.value = withTiming(1, timing.normal);
}
} else if (!open && wasOpen.current) {
// Single exit path — backdrop press, onRequestClose, and closeSheet
// all set `open` false and land here; the unmount follows the
// animation's completion callback (exits run faster than entrances).
if (reduce) {
sheetY.value = 400;
backdropOpacity.value = 0;
setRendered(false);
} else {
sheetY.value = withTiming(400, timing.fast);
backdropOpacity.value = withTiming(0, timing.fast, (f) => {
if (f) runOnJS(setRendered)(false);
});
}
}
wasOpen.current = open;
}, [open, reduce, sheetY, backdropOpacity]);
const closeSheet = useCallback(() => {
haptics.light();
setQuery("");
setOpen(false);
}, [haptics]);
const sheetStyle = useAnimatedStyle(() => ({
transform: [{ translateY: sheetY.value }],
}));
const backdropStyle = useAnimatedStyle(() => ({
opacity: backdropOpacity.value,
}));
return (
// Owns its width so the trigger's flex-1 chip row fills the column.
<View ref={ref} className={cn("w-full", className)} {...props}>
{/* Trigger */}
<AnimatedPressable
onPress={() => {
if (disabled) return;
haptics.selection();
setOpen(true);
}}
onPressIn={triggerPress.onPressIn}
onPressOut={triggerPress.onPressOut}
style={triggerPress.animatedStyle}
disabled={disabled}
className={cn(
"min-h-[48px] flex-row items-center justify-between rounded-xl border border-input bg-background px-3.5 py-2.5",
disabled && "opacity-50"
)}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityState={{ disabled, expanded: open }}
// RNW drops accessibilityState — mirror for the web trigger.
aria-expanded={open}
aria-disabled={disabled || undefined}
accessibilityHint="Opens a picker"
>
<View className="flex-1 flex-row flex-wrap gap-1.5">
{selectedLabels.length === 0 ? (
<Text className="text-base text-muted-foreground">{placeholder}</Text>
) : (
selectedLabels.map((opt) => (
<SelectedChip key={opt.value} opt={opt} onRemove={remove} />
))
)}
</View>
<Icon name="chevron-down" size="sm" color="mutedForeground" />
</AnimatedPressable>
{/* Bottom sheet picker */}
<Modal transparent visible={rendered} animationType="none" statusBarTranslucent onRequestClose={closeSheet}>
<View className="flex-1" style={{ width: "100%", height: "100%" }} pointerEvents="box-none">
<Animated.View style={[{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }, backdropStyle]} className="bg-overlay" pointerEvents="auto">
<Pressable style={{ flex: 1 }} onPress={closeSheet} accessibilityRole="button" accessibilityLabel="Close" />
</Animated.View>
<Animated.View
style={[{ position: "absolute", left: 0, right: 0, bottom: 0, paddingBottom: Math.max(40, insets.bottom + 16) }, sheetStyle]}
className="max-h-[80%] rounded-t-3xl border-t border-border bg-card"
accessibilityRole="menu"
accessibilityLabel={`${accessibilityLabel} options`}
>
{/* Grab handle */}
<View className="items-center pt-3 pb-1">
<View className="h-1.5 w-10 rounded-full bg-muted-foreground/40" />
</View>
{/* Search */}
<View className="px-4 py-2">
<View className="flex-row items-center gap-2 rounded-xl bg-muted px-3 py-2">
<Icon name="search" size="sm" color="mutedForeground" />
<TextInput
value={query}
onChangeText={setQuery}
placeholder={searchPlaceholder}
className="flex-1 text-sm text-foreground placeholder:text-muted-foreground"
autoCapitalize="none"
autoCorrect={false}
accessibilityLabel={searchPlaceholder}
/>
{query ? (
<Pressable onPress={() => setQuery("")} hitSlop={8} accessibilityRole="button" accessibilityLabel="Clear search">
<Icon name="x" size="sm" color="mutedForeground" />
</Pressable>
) : null}
</View>
</View>
{/* Options list */}
<ScrollView className="px-2" keyboardShouldPersistTaps="handled">
{filtered.length === 0 ? (
<Text className="py-8 text-center text-sm text-muted-foreground">{emptyText}</Text>
) : (
filtered.map((opt, i) => (
// Cascade caps at index 8 — deeper rows share the
// last delay so long lists don't pile up latency.
<StaggeredItem key={opt.value} index={Math.min(i, 8)}>
<OptionRow
opt={opt}
isSelected={selected.includes(opt.value)}
onToggle={toggle}
/>
</StaggeredItem>
))
)}
</ScrollView>
</Animated.View>
</View>
</Modal>
</View>
);
}
);
MultiSelect.displayName = "MultiSelect";
| options | MultiSelectOption[] | ||
| value? | string[] | Controlled selected values. | |
| defaultValue? | string[] | Initial selected values (uncontrolled). | |
| onValueChange? | (value: string[]) => void | ||
| placeholder? | string | Placeholder shown when nothing is selected. | |
| searchPlaceholder? | string | Search placeholder. | |
| emptyText? | string | Empty state text when no options match. | |
| disabled? | boolean | ||
| accessibilityLabel? | string | Accessible label. | |
| className? | string |
Mobile-native multi-select with chip trigger, bottom-sheet picker, search filtering, and selected-item chips. Spring-animated sheet entrance, haptic selection feedback.
import { MultiSelect } from "@/components/ui/multi-select/multi-select";
export function Example() {
return (
<MultiSelect />
);
} import * as React from "react";
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { View, Text, Pressable, Modal, TextInput, ScrollView, type ViewProps, } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
runOnJS,
} from "react-native-reanimated";
import { cn, useHaptic, useReducedMotion, spring, timing, usePressAnimation, AnimatedPressable, StaggeredItem } from "@lumioui/core";
import { Icon, type IconName } from "@lumioui/ui";
import { useSafeAreaInsets } from "react-native-safe-area-context";
/**
* MultiSelect — mobile-native multi-select with a chip trigger, a bottom
* sheet picker, search filtering, and selected-item chips.
* <MultiSelect
* options={[{ value: "apple", label: "Apple" }, { value: "banana", label: "Banana" }]}
* value={selected}
* onValueChange={setSelected}
* placeholder="Pick fruits"
* />
*/
export interface MultiSelectOption {
value: string;
label: string;
icon?: IconName;
disabled?: boolean;
}
/** Selected-value chip — extracted so it can own mount/remove pop and a
* usePressAnimation hook (can't call hooks inside the parent's map). */
function SelectedChip({ opt, onRemove }: { opt: MultiSelectOption; onRemove: (value: string) => void }) {
const press = usePressAnimation({ pressedScale: 0.85 });
const reduce = useReducedMotion();
// Chip pop: springs in on mount, shrinks out before removal commits so
// the add/remove reads as a physical pop, not a list reflow.
const pop = useSharedValue(reduce ? 1 : 0);
useEffect(() => {
pop.value = reduce ? 1 : withSpring(1, spring.bouncy);
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only entrance
}, []);
const popStyle = useAnimatedStyle(() => ({
transform: [{ scale: pop.value }],
opacity: pop.value,
}));
const handleRemove = () => {
if (reduce) {
onRemove(opt.value);
return;
}
pop.value = withTiming(0, timing.fast, (finished) => {
if (finished) runOnJS(onRemove)(opt.value);
});
};
return (
<Animated.View style={popStyle} className="flex-row items-center gap-1 rounded-full bg-primary/10 pl-2.5 pr-1 py-1">
{opt.icon ? <Icon name={opt.icon} size="sm" color="primary" /> : null}
<Text className="text-sm font-medium text-primary">{opt.label}</Text>
{/* 24pt visual + 10pt hitSlop = 44pt touch target. */}
<AnimatedPressable
onPress={handleRemove}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
style={press.animatedStyle}
hitSlop={10}
className="h-6 w-6 items-center justify-center"
accessibilityRole="button"
accessibilityLabel={`Remove ${opt.label}`}
>
<Icon name="x" size="sm" color="primary" />
</AnimatedPressable>
</Animated.View>
);
}
/** Option row in the picker sheet — extracted for per-row press animation
* and a check-pop on toggle. */
function OptionRow({ opt, isSelected, onToggle }: { opt: MultiSelectOption; isSelected: boolean; onToggle: (value: string) => void }) {
const press = usePressAnimation({ pressedScale: 0.98 });
const reduce = useReducedMotion();
// Check pop — the tick springs in with a touch of overshoot on select.
const check = useSharedValue(isSelected ? 1 : 0);
useEffect(() => {
check.value = reduce ? (isSelected ? 1 : 0) : withSpring(isSelected ? 1 : 0, spring.bouncy);
}, [isSelected, check, reduce]);
const checkStyle = useAnimatedStyle(() => ({
transform: [{ scale: check.value }],
opacity: check.value,
}));
return (
<AnimatedPressable
onPress={() => !opt.disabled && onToggle(opt.value)}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
disabled={opt.disabled}
style={press.animatedStyle}
className={cn(
"w-full flex-row items-center gap-3 rounded-xl px-3 py-3.5",
opt.disabled && "opacity-50"
)}
accessibilityRole="checkbox"
accessibilityState={{ checked: isSelected, disabled: opt.disabled }}
// RNW drops accessibilityState — mirror so the checkbox role reports
// its checked/disabled state on web.
aria-checked={isSelected}
aria-disabled={opt.disabled || undefined}
accessibilityLabel={opt.label}
>
{opt.icon ? <Icon name={opt.icon} size="sm" color="mutedForeground" /> : null}
<Text className="flex-1 text-base text-foreground">{opt.label}</Text>
<View className={cn("h-5 w-5 items-center justify-center rounded-md border-2", isSelected ? "border-primary bg-primary" : "border-muted-foreground/30")}>
<Animated.View style={checkStyle}>
<Icon name="check" size="sm" color="primaryForeground" />
</Animated.View>
</View>
</AnimatedPressable>
);
}
export interface MultiSelectProps extends ViewProps {
options: MultiSelectOption[];
/** Controlled selected values. */
value?: string[];
/** Initial selected values (uncontrolled). */
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
/** Placeholder shown when nothing is selected. */
placeholder?: string;
/** Search placeholder. */
searchPlaceholder?: string;
/** Empty state text when no options match. */
emptyText?: string;
disabled?: boolean;
/** Accessible label. */
accessibilityLabel?: string;
className?: string;
}
export const MultiSelect = forwardRef<React.ElementRef<typeof View>, MultiSelectProps>(
function MultiSelect(
{ options, value, defaultValue, onValueChange, placeholder = "Select…", searchPlaceholder = "Search…", emptyText = "No results", disabled, accessibilityLabel = "Multi select", className, ...props },
ref
) {
const haptics = useHaptic();
const reduce = useReducedMotion();
const insets = useSafeAreaInsets();
const triggerPress = usePressAnimation({ pressedScale: 0.98 });
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [internal, setInternal] = useState<string[]>(defaultValue ?? []);
const selected = value ?? internal;
const filtered = useMemo(() => {
if (!query.trim()) return options;
const q = query.toLowerCase();
return options.filter((o) => o.label.toLowerCase().includes(q));
}, [options, query]);
const toggle = useCallback(
(val: string) => {
const next = selected.includes(val) ? selected.filter((v) => v !== val) : [...selected, val];
if (value === undefined) setInternal(next);
onValueChange?.(next);
haptics.selection();
},
[selected, value, onValueChange, haptics]
);
const remove = useCallback(
(val: string) => {
const next = selected.filter((v) => v !== val);
if (value === undefined) setInternal(next);
onValueChange?.(next);
haptics.light();
},
[selected, value, onValueChange, haptics]
);
const selectedLabels = useMemo(
() => selected.map((v) => options.find((o) => o.value === v)).filter(Boolean) as MultiSelectOption[],
[selected, options]
);
// Sheet animation
const sheetY = useSharedValue(0);
const backdropOpacity = useSharedValue(0);
// Keeps the Modal mounted while the exit runs — `visible={open}` would
// unmount before the slide-down plays.
const [rendered, setRendered] = useState(false);
const wasOpen = useRef(false);
useEffect(() => {
if (open && !wasOpen.current) {
setRendered(true);
if (reduce) {
sheetY.value = 0;
backdropOpacity.value = 1;
} else {
sheetY.value = 400;
backdropOpacity.value = 0;
sheetY.value = withSpring(0, spring.surface);
backdropOpacity.value = withTiming(1, timing.normal);
}
} else if (!open && wasOpen.current) {
// Single exit path — backdrop press, onRequestClose, and closeSheet
// all set `open` false and land here; the unmount follows the
// animation's completion callback (exits run faster than entrances).
if (reduce) {
sheetY.value = 400;
backdropOpacity.value = 0;
setRendered(false);
} else {
sheetY.value = withTiming(400, timing.fast);
backdropOpacity.value = withTiming(0, timing.fast, (f) => {
if (f) runOnJS(setRendered)(false);
});
}
}
wasOpen.current = open;
}, [open, reduce, sheetY, backdropOpacity]);
const closeSheet = useCallback(() => {
haptics.light();
setQuery("");
setOpen(false);
}, [haptics]);
const sheetStyle = useAnimatedStyle(() => ({
transform: [{ translateY: sheetY.value }],
}));
const backdropStyle = useAnimatedStyle(() => ({
opacity: backdropOpacity.value,
}));
return (
// Owns its width so the trigger's flex-1 chip row fills the column.
<View ref={ref} className={cn("w-full", className)} {...props}>
{/* Trigger */}
<AnimatedPressable
onPress={() => {
if (disabled) return;
haptics.selection();
setOpen(true);
}}
onPressIn={triggerPress.onPressIn}
onPressOut={triggerPress.onPressOut}
style={triggerPress.animatedStyle}
disabled={disabled}
className={cn(
"min-h-[48px] flex-row items-center justify-between rounded-xl border border-input bg-background px-3.5 py-2.5",
disabled && "opacity-50"
)}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityState={{ disabled, expanded: open }}
// RNW drops accessibilityState — mirror for the web trigger.
aria-expanded={open}
aria-disabled={disabled || undefined}
accessibilityHint="Opens a picker"
>
<View className="flex-1 flex-row flex-wrap gap-1.5">
{selectedLabels.length === 0 ? (
<Text className="text-base text-muted-foreground">{placeholder}</Text>
) : (
selectedLabels.map((opt) => (
<SelectedChip key={opt.value} opt={opt} onRemove={remove} />
))
)}
</View>
<Icon name="chevron-down" size="sm" color="mutedForeground" />
</AnimatedPressable>
{/* Bottom sheet picker */}
<Modal transparent visible={rendered} animationType="none" statusBarTranslucent onRequestClose={closeSheet}>
<View className="flex-1" style={{ width: "100%", height: "100%" }} pointerEvents="box-none">
<Animated.View style={[{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }, backdropStyle]} className="bg-overlay" pointerEvents="auto">
<Pressable style={{ flex: 1 }} onPress={closeSheet} accessibilityRole="button" accessibilityLabel="Close" />
</Animated.View>
<Animated.View
style={[{ position: "absolute", left: 0, right: 0, bottom: 0, paddingBottom: Math.max(40, insets.bottom + 16) }, sheetStyle]}
className="max-h-[80%] rounded-t-3xl border-t border-border bg-card"
accessibilityRole="menu"
accessibilityLabel={`${accessibilityLabel} options`}
>
{/* Grab handle */}
<View className="items-center pt-3 pb-1">
<View className="h-1.5 w-10 rounded-full bg-muted-foreground/40" />
</View>
{/* Search */}
<View className="px-4 py-2">
<View className="flex-row items-center gap-2 rounded-xl bg-muted px-3 py-2">
<Icon name="search" size="sm" color="mutedForeground" />
<TextInput
value={query}
onChangeText={setQuery}
placeholder={searchPlaceholder}
className="flex-1 text-sm text-foreground placeholder:text-muted-foreground"
autoCapitalize="none"
autoCorrect={false}
accessibilityLabel={searchPlaceholder}
/>
{query ? (
<Pressable onPress={() => setQuery("")} hitSlop={8} accessibilityRole="button" accessibilityLabel="Clear search">
<Icon name="x" size="sm" color="mutedForeground" />
</Pressable>
) : null}
</View>
</View>
{/* Options list */}
<ScrollView className="px-2" keyboardShouldPersistTaps="handled">
{filtered.length === 0 ? (
<Text className="py-8 text-center text-sm text-muted-foreground">{emptyText}</Text>
) : (
filtered.map((opt, i) => (
// Cascade caps at index 8 — deeper rows share the
// last delay so long lists don't pile up latency.
<StaggeredItem key={opt.value} index={Math.min(i, 8)}>
<OptionRow
opt={opt}
isSelected={selected.includes(opt.value)}
onToggle={toggle}
/>
</StaggeredItem>
))
)}
</ScrollView>
</Animated.View>
</View>
</Modal>
</View>
);
}
);
MultiSelect.displayName = "MultiSelect";