AniUI

Combobox

Searchable select with multi-select, groups, clear button, and custom rendering.

Installation#

npx @aniui/cli add combobox
Web preview — components render natively on iOS & Android
import { useState } from "react";
import { Combobox } from "@/components/ui/combobox";

const frameworks = [
  { label: "React Native", value: "rn" },
  { label: "Flutter", value: "flutter" },
  { label: "SwiftUI", value: "swiftui" },
  { label: "Jetpack Compose", value: "compose" },
];

export function MyScreen() {
  const [value, setValue] = useState("");

  return (
    <Combobox
      options={frameworks}
      value={value}
      onValueChange={setValue}
      placeholder="Select framework..."
      searchPlaceholder="Search frameworks..."
    />
  );
}

Usage#

app/index.tsx
import { useState } from "react";
import { Combobox } from "@/components/ui/combobox";

const frameworks = [
  { label: "React Native", value: "rn" },
  { label: "Flutter", value: "flutter" },
  { label: "SwiftUI", value: "swiftui" },
  { label: "Jetpack Compose", value: "compose" },
];

export function MyScreen() {
  const [value, setValue] = useState("");

  return (
    <Combobox
      options={frameworks}
      value={value}
      onValueChange={setValue}
      placeholder="Select framework..."
      searchPlaceholder="Search frameworks..."
    />
  );
}

Multiple Selection#

Enable multi-select mode with the multiple prop. Selected items display as removable chips.

Multi-select
import { useState } from "react";
import { Combobox } from "@/components/ui/combobox";

const tags = [
  { label: "React Native", value: "rn" },
  { label: "TypeScript", value: "ts" },
  { label: "NativeWind", value: "nw" },
  { label: "Expo", value: "expo" },
];

export function MultiSelect() {
  const [selected, setSelected] = useState<string[]>([]);

  return (
    <Combobox
      multiple
      options={tags}
      selectedValues={selected}
      onSelectedValuesChange={setSelected}
      placeholder="Select tags..."
    />
  );
}

Groups#

Organize options under group headers using the groups prop. Groups render as sticky section headers.

Grouped options
<Combobox
  groups={[
    {
      label: "Frameworks",
      options: [
        { label: "React Native", value: "rn" },
        { label: "Flutter", value: "flutter" },
      ],
    },
    {
      label: "Languages",
      options: [
        { label: "TypeScript", value: "ts" },
        { label: "Dart", value: "dart" },
      ],
    },
  ]}
  options={[]}
  value={value}
  onValueChange={setValue}
/>

Clear Button#

Add a clear button to reset the selection with clearable.

Clearable
<Combobox
  options={frameworks}
  value={value}
  onValueChange={setValue}
  clearable
  placeholder="Select..."
/>

Custom Items#

Use the renderItem prop for custom option rendering.

Custom item rendering
<Combobox
  options={frameworks}
  value={value}
  onValueChange={setValue}
  renderItem={(option, selected) => (
    <View className="flex-row items-center px-5 py-3 gap-3">
      <View className={cn(
        "h-4 w-4 rounded-full border",
        selected ? "bg-primary border-primary" : "border-input"
      )} />
      <Text className="text-foreground">{option.label}</Text>
    </View>
  )}
/>

Invalid#

Show error styling with the invalid prop.

Invalid state
<Combobox
  options={frameworks}
  value={value}
  onValueChange={setValue}
  invalid
  placeholder="Select framework..."
/>

Disabled#

Prevent interaction with the disabled prop.

Disabled
<Combobox
  options={frameworks}
  value={value}
  onValueChange={setValue}
  disabled
  placeholder="Disabled..."
/>

Use mode="popup" for a button-like trigger style.

Popup mode
<Combobox
  options={frameworks}
  value={value}
  onValueChange={setValue}
  mode="popup"
  placeholder="Choose..."
/>

Props#

ComboboxProps#

PropTypeDefault
options
ComboboxOption[]
-
value
string
-
onValueChange
(value: string) => void
-
multiple
boolean
false
selectedValues
string[]
[]
onSelectedValuesChange
(values: string[]) => void
-
groups
ComboboxGroup[]
-
renderItem
(option, selected) => ReactNode
-
invalid
boolean
false
disabled
boolean
false
clearable
boolean
false
autoHighlight
boolean
false
mode
"select" | "popup"
"select"
placeholder
string
"Select..."
searchPlaceholder
string
"Search..."
emptyText
string
"No results found"
className
string
-
triggerClassName
string
-

ComboboxOption#

PropTypeDefault
label
string
-
value
string
-
disabled
boolean
-

ComboboxGroup#

PropTypeDefault
label
string
-
options
ComboboxOption[]
-

Also accepts all View props from React Native.

Accessibility#

  • Searchable select with type-to-filter functionality.
  • accessibilityRole="button" on trigger and options.
  • accessibilityState tracks selected and disabled states.
  • Clear button has accessibilityLabel="Clear selection".
  • Multi-select chips have individual accessibilityLabel for removal.
  • Uses logical properties for RTL support.

Source#

components/ui/combobox.tsx
import React, { useState, useMemo, useCallback, useEffect } from "react";
import {
  View,
  TextInput,
  Pressable,
  Text,
  FlatList,
  SectionList,
  Modal,
  ScrollView,
  useColorScheme,
  Keyboard,
  Platform,
} from "react-native";
import { cn } from "@/lib/utils";
import { X, ChevronDown } from "lucide-react-native";

export interface ComboboxOption {
  label: string;
  value: string;
  disabled?: boolean;
}

export interface ComboboxGroup {
  label: string;
  options: ComboboxOption[];
}

export interface ComboboxProps extends React.ComponentPropsWithoutRef<typeof View> {
  className?: string;
  options: ComboboxOption[];
  value?: string;
  onValueChange?: (value: string) => void;
  placeholder?: string;
  searchPlaceholder?: string;
  emptyText?: string;
  multiple?: boolean;
  selectedValues?: string[];
  onSelectedValuesChange?: (values: string[]) => void;
  groups?: ComboboxGroup[];
  renderItem?: (option: ComboboxOption, selected: boolean) => React.ReactNode;
  invalid?: boolean;
  disabled?: boolean;
  clearable?: boolean;
  autoHighlight?: boolean;
  mode?: "select" | "popup";
  triggerClassName?: string;
}

// Inline chip for multi-select display
function Chip({ label, onRemove }: { label: string; onRemove: () => void }) {
  return (
    <View className="flex-row items-center rounded-full bg-secondary ps-2.5 pe-1 py-0.5 me-1.5 mb-1">
      <Text className="text-xs text-secondary-foreground me-1">{label}</Text>
      <Pressable
        onPress={onRemove}
        className="rounded-full p-0.5"
        accessible={true}
        accessibilityRole="button"
        accessibilityLabel={`Remove ${label}`}
      >
        <X size={12} color="#71717a" strokeWidth={2.5} />
      </Pressable>
    </View>
  );
}

export function Combobox({
  className,
  options,
  value,
  onValueChange,
  placeholder = "Select...",
  searchPlaceholder = "Search...",
  emptyText = "No results found",
  multiple = false,
  selectedValues = [],
  onSelectedValuesChange,
  groups,
  renderItem: renderItemProp,
  invalid = false,
  disabled = false,
  clearable = false,
  autoHighlight = false,
  mode = "select",
  triggerClassName,
  ...props
}: ComboboxProps) {
  const [open, setOpen] = useState(false);
  const [search, setSearch] = useState("");
  const [kbHeight, setKbHeight] = useState(0);
  const dark = useColorScheme() === "dark";
  const caret = dark ? "#fafafa" : "#18181b";

  // RN Modal doesn't resize for the keyboard on Android (and the sheet is
  // bottom-anchored), so track the keyboard height and pad the sheet up.
  useEffect(() => {
    const showEvt = Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow";
    const hideEvt = Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide";
    const show = Keyboard.addListener(showEvt, (e) => setKbHeight(e.endCoordinates.height));
    const hide = Keyboard.addListener(hideEvt, () => setKbHeight(0));
    return () => {
      show.remove();
      hide.remove();
    };
  }, []);

  const allOptions = useMemo(
    () => (groups ? groups.flatMap((g) => g.options) : options),
    [groups, options]
  );

  const selected = allOptions.find((o) => o.value === value);

  const isSelected = useCallback(
    (val: string) => (multiple ? selectedValues.includes(val) : val === value),
    [multiple, selectedValues, value]
  );

  // Filter logic
  const filterFn = (opts: ComboboxOption[]) =>
    opts.filter((o) => o.label.toLowerCase().includes(search.toLowerCase()));

  const filteredOptions = useMemo(() => filterFn(allOptions), [allOptions, search]);

  const filteredSections = useMemo(() => {
    if (!groups) return [];
    return groups
      .map((g) => ({ title: g.label, data: filterFn(g.options) }))
      .filter((s) => s.data.length > 0);
  }, [groups, search]);

  const handleSelect = (val: string) => {
    if (multiple) {
      const next = selectedValues.includes(val)
        ? selectedValues.filter((v) => v !== val)
        : [...selectedValues, val];
      onSelectedValuesChange?.(next);
    } else {
      onValueChange?.(val);
      setOpen(false);
    }
    setSearch("");
  };

  const handleClear = () => {
    if (multiple) {
      onSelectedValuesChange?.([]);
    } else {
      onValueChange?.("");
    }
  };

  const hasValue = multiple ? selectedValues.length > 0 : !!value;

  // Trigger display
  const triggerLabel = multiple
    ? selectedValues.length > 0
      ? `${selectedValues.length} selected`
      : placeholder
    : selected?.label ?? placeholder;

  const renderOption = (item: ComboboxOption) => {
    const sel = isSelected(item.value);
    if (renderItemProp) {
      return (
        <Pressable
          key={item.value}
          onPress={() => !item.disabled && handleSelect(item.value)}
          disabled={item.disabled}
          className={cn(item.disabled && "opacity-40")}
        >
          {renderItemProp(item, sel)}
        </Pressable>
      );
    }
    return (
      <Pressable
        className={cn(
          "ps-5 pe-5 py-3",
          sel && "bg-accent",
          item.disabled && "opacity-40",
          autoHighlight && filteredOptions[0]?.value === item.value && !sel && "bg-accent/50"
        )}
        onPress={() => !item.disabled && handleSelect(item.value)}
        disabled={item.disabled}
        accessibilityRole="button"
        accessibilityState={{ selected: sel }}
      >
        <Text className="text-foreground">{item.label}</Text>
      </Pressable>
    );
  };

  return (
    <View className={cn("", className)} {...props}>
      {/* Trigger */}
      <Pressable
        className={cn(
          "flex-row items-center justify-between min-h-12 px-4 rounded-md border bg-background",
          invalid ? "border-destructive" : "border-input",
          disabled && "opacity-50",
          mode === "popup" && "bg-secondary",
          triggerClassName
        )}
        onPress={() => !disabled && setOpen(true)}
        disabled={disabled}
        accessible={true}
        accessibilityRole="button"
        accessibilityLabel={selected ? `Selected: ${selected.label}` : placeholder}
        accessibilityState={{ disabled }}
      >
        <Text
          className={cn(
            "text-base flex-1",
            hasValue ? "text-foreground" : "text-muted-foreground",
            invalid && "text-destructive"
          )}
          numberOfLines={1}
        >
          {triggerLabel}
        </Text>
        <View className="flex-row items-center">
          {clearable && hasValue && (
            <Pressable
              onPress={(e) => { e.stopPropagation(); handleClear(); }}
              className="p-1 me-1"
              accessible={true}
              accessibilityRole="button"
              accessibilityLabel="Clear selection"
            >
              <X size={14} color="#71717a" strokeWidth={2} />
            </Pressable>
          )}
          <ChevronDown size={16} color="#71717a" strokeWidth={2} />
        </View>
      </Pressable>

      {/* Multi-select chips */}
      {multiple && selectedValues.length > 0 && (
        <ScrollView horizontal showsHorizontalScrollIndicator={false} className="mt-2">
          <View className="flex-row flex-wrap">
            {selectedValues.map((val) => {
              const opt = allOptions.find((o) => o.value === val);
              return opt ? (
                <Chip
                  key={val}
                  label={opt.label}
                  onRemove={() => {
                    onSelectedValuesChange?.(selectedValues.filter((v) => v !== val));
                  }}
                />
              ) : null;
            })}
          </View>
        </ScrollView>
      )}

      {/* Modal */}
      <Modal visible={open} transparent animationType="fade" onRequestClose={() => setOpen(false)}>
        <Pressable
          className="flex-1 bg-black/50 justify-end"
          style={{ paddingBottom: kbHeight }}
          onPress={() => setOpen(false)}
        >
          <Pressable className="bg-card rounded-t-2xl max-h-96 pb-8" onPress={() => {}}>
            <View className="items-center py-3">
              <View className="w-10 h-1 rounded-full bg-muted" />
            </View>
            <View className="px-4 pb-3">
              <TextInput
                className="min-h-10 px-3 rounded-md border border-input bg-background text-foreground text-base"
                placeholder={searchPlaceholder}
                placeholderTextColor={dark ? "#a1a1aa" : "#71717a"}
                keyboardAppearance={dark ? "dark" : "light"}
                selectionColor={caret}
                cursorColor={caret}
                value={search}
                onChangeText={setSearch}
                autoFocus
                accessibilityLabel="Search options"
              />
            </View>
            {groups ? (
              <SectionList
                sections={filteredSections}
                keyExtractor={(item) => item.value}
                renderSectionHeader={({ section }) => (
                  <View className="px-5 py-2 bg-card">
                    <Text className="text-xs font-semibold text-muted-foreground uppercase">
                      {section.title}
                    </Text>
                  </View>
                )}
                renderItem={({ item }) => renderOption(item)}
                ListEmptyComponent={
                  <Text className="text-muted-foreground text-center py-4">{emptyText}</Text>
                }
              />
            ) : (
              <FlatList
                data={filteredOptions}
                keyExtractor={(item) => item.value}
                ListEmptyComponent={
                  <Text className="text-muted-foreground text-center py-4">{emptyText}</Text>
                }
                renderItem={({ item }) => renderOption(item)}
              />
            )}
          </Pressable>
        </Pressable>
      </Modal>
    </View>
  );
}