Skip to content
Native Animation

React Native Dropdown List: Search and Multi-Select

By Native Animation · Updated

A React Native dropdown list stores a form choice: use a single select for a short list, a searchable selection surface for many options, and a multi-select when several values are valid. Keep accepted IDs separate from search results. The Expo examples below cover a controlled single select and a searchable, grouped multi-select with persistent selections.

If you arrived looking for a select option in React Native, the React DOM select element only solves the web case. Start with the Expo SDK 58 picker when a standard platform selection interface meets your needs. A custom select is appropriate when the option presentation must match your product across platforms. For a few short choices, a segmented control keeps all options visible without opening a layer.

A radio group keeps a short list of descriptive form choices visible. For commands on a particular item, see the context menu guide; for anchored placement, see the overlays guide.

Choose single select, searchable select, or multi-select

Form value versus action menu

A select stores one or more form values. An action menu runs a command. Use the option's stable ID for the stored value and its label for display; translating a label should not change the saved choice. Compare the radio group, segmented control, and overlays alternatives before adding another selection surface.

Compare a platform picker with a custom select

CaseStarting point and demoState ownershipSelection behavior
Standard single fieldExpo platform pickerParent's accepted valuePlatform selection UI
Short branded listMorphing Select MenuInternal selection; host receives onChangeOne option, up to eight entries
Long searchable listSearchable Select Sheet in single modeParent can control IDs, query, and visibilitySearch then choose; selected option can be removed
Several or grouped choicesSearchable Select Sheet in multiple modeParent's accepted ID arrayImmediate add/remove requests, persistent chips

The modal tutorial below remains a controlled single-select foundation. A multi-select is not a radio group with several checked rows: use checkbox semantics, an accepted ID collection, and an explicit rule for clearing and selection limits.

Add search without losing selection

Stable IDs across filters and remote pages

Keep selectedIds separate from the current matching options. Suppose Design and Research are selected. Changing query from an empty string to Delivery should change the result list, not replace the selected IDs with the intersection of matches. Clearing search should restore all matching choices with both selections intact.

After copying Searchable Select Sheet with the CLI, this complete local-search host keeps query, selection, and visibility independent. Its selection is immediate: Done closes the sheet and does not commit a deferred draft.

import type { ComponentRef } from 'react';
import { useRef, useState } from 'react';
import { Button, Platform, Text, View } from 'react-native';
import { SearchableSelectSheet } from './searchable-select-sheet';

const teamOptions = [
  { id: 'design', label: 'Design', groupId: 'product' },
  { id: 'research', label: 'Research', groupId: 'product' },
  { id: 'delivery', label: 'Delivery', groupId: 'operations' },
];
export function TeamSelection() {
  const [selectedIds, setSelectedIds] = useState<string[]>(['design', 'research']);
  const [query, setQuery] = useState('');
  const [open, setOpen] = useState(false);
  const trigger = useRef<ComponentRef<typeof View>>(null);
  return (
    <View style={{ flex: 1 }}>
      <View accessibilityElementsHidden={open}
        importantForAccessibility={open ? 'no-hide-descendants' : 'auto'}
        {...(Platform.OS === 'web' ? { inert: open } : {})}>
        <View ref={trigger}>
          <Button title="Choose teams" onPress={() => setOpen(true)} />
        </View>
        <Text>Accepted IDs: {selectedIds.join(', ') || 'None'}</Text>
      </View>
      <SearchableSelectSheet options={teamOptions}
        groups={[{ id: 'product', label: 'Product' }, { id: 'operations', label: 'Operations' }]}
        selectedIds={selectedIds} onSelectionChange={setSelectedIds}
        query={query} onQueryChange={setQuery} searchMode="local"
        open={open} onOpenChange={setOpen} selectionMode="multiple" maxSelected={5}
        onRestoreFocus={() => {
          if (Platform.OS === 'web') {
            const host = trigger.current as unknown as HTMLElement | null;
            host?.querySelector<HTMLElement>('[role="button"],button')?.focus();
          }
        }}
        labels={{ title: 'Choose teams', search: 'Search teams' }}
        style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }} />
    </View>
  );
}

Mount the host inside GestureHandlerRootView and a measured, safe-area container; install the piece's expo-image, Gesture Handler, Reanimated, and Worklets dependencies with Expo. The sheet owns its keyboard avoidance, so do not add a second avoidance layer. This host restores web button focus; add your native trigger accessibility-focus integration through onRestoreFocus and test it with your presentation stack. The existing single-select example below shows a native focus helper.

The controlled query defaults to remote mode in this component, so the example explicitly sets searchMode to local. In remote mode, send the query to your data layer and supply its options page. Cancel superseded requests or compare request IDs before accepting a response; an old response must not replace a newer query's results.

Empty, loading, and failed results

Use loading, error, and onRetry for distinct result states. No matches is different from a failed request. Preserve the query and chosen IDs through loading and retry. The sheet caches seen labels across pages; unknown selected IDs remain as raw-ID chips and can be reported with onDataIssue. Resolve those labels in the host rather than silently dropping IDs that the current page omits.

A parent may reject onSelectionChange by retaining selectedIds. Set maxSelected for a UI limit, then validate the accepted collection again when submitting the form. In single mode, tapping the current selection can remove it; enforce a required field at the host boundary if empty is invalid.

React Native multi-select: chips, select all, and limits

A React Native multi-select stores an accepted collection of stable IDs. Chips make those choices visible when search hides their rows; each chip needs a named remove action. A grouped or sectioned list changes how options are presented, not how identity or selection is stored. Searchable Select Sheet already provides these pieces; bulk selection is a host policy rather than a built-in selectAll prop.

Define select all before implementing it

ControlScopeRequired behavior
Select all matchingEnabled options in the current local filterPreserve selected IDs outside the filter
Clear matchingEnabled selected options in that filterRetain disabled and out-of-filter selections
Select current pageOnly the loaded remote pageLabel it as a page operation, not every server match
Select every server matchA server-defined query result setNeeds a separate server contract for totals, exclusions, and limits

The example below rejects a bulk addition as a whole when it would exceed the limit. Silently filling the remaining slots makes the chosen subset depend on result ordering. Existing unknown IDs count toward the limit, and disabled selections remain locked, matching the sheet's individual-row policy. Clear matching can remove enabled selections even when the field is already at its limit.

Add bulk selection in the host

This complete local-search host uses the same matcher for bulk operations and sheet rows. The bulk controls are outside the inline modal surface and available while it is closed. Type a filter in the host or search inside the sheet, close with Done, then use Select all matching or Clear matching for that retained query. To place bulk controls inside the sheet instead, adapt its copied source and include them in its focus order; there is no header-actions prop in the current API.

import type { ComponentRef } from 'react';
import { useRef, useState } from 'react';
import {
  AccessibilityInfo, Button, findNodeHandle, Platform, Pressable, Text, TextInput, View,
} from 'react-native';
import { SearchableSelectSheet } from './searchable-select-sheet';

type Choice = { id: string; label: string; disabled?: boolean };
const choices: Choice[] = [
  { id: 'design', label: 'Design' },
  { id: 'research', label: 'Design research' },
  { id: 'delivery', label: 'Delivery' },
  { id: 'required', label: 'Required team', disabled: true },
];
const limit = 3;
const matches = (option: Choice, query: string) =>
  option.label.toLowerCase().includes(query.trim().toLowerCase());

export function bulkSelection(
  selected: readonly string[], scope: readonly Choice[], add: boolean, max: number,
): string[] | null {
  const ids = [...new Set(selected)];
  const enabled = new Set(scope.filter((option) => !option.disabled).map((option) => option.id));
  if (!add) return ids.filter((id) => !enabled.has(id));
  const next = [...new Set([...ids, ...enabled])];
  return next.length > max ? null : next;
}

function restoreTrigger(control: ComponentRef<typeof View> | null) {
  if (!control) return;
  if (Platform.OS === 'web') control.focus();
  else {
    const tag = findNodeHandle(control);
    if (tag) AccessibilityInfo.setAccessibilityFocus(tag);
  }
}

export function BulkTeamSelection() {
  const [selectedIds, setSelectedIds] = useState<string[]>(['required']);
  const [query, setQuery] = useState('');
  const [open, setOpen] = useState(false);
  const [notice, setNotice] = useState('');
  const trigger = useRef<ComponentRef<typeof View>>(null);
  const scope = choices.filter((option) => matches(option, query));
  const hasEnabled = scope.some((option) => !option.disabled);
  const hasSelectedMatch = scope.some((option) => !option.disabled && selectedIds.includes(option.id));

  function changeMatching(add: boolean) {
    const next = bulkSelection(selectedIds, scope, add, limit);
    if (next === null) {
      setNotice(`Select up to ${limit} teams. No selections changed.`);
      return;
    }
    setSelectedIds(next);
    setNotice(`${next.length} teams selected.`);
  }

  return (
    <View style={{ flex: 1 }}>
      <View accessibilityElementsHidden={open}
        importantForAccessibility={open ? 'no-hide-descendants' : 'auto'}
        {...(Platform.OS === 'web' ? { inert: open } : {})}>
        <TextInput accessibilityLabel="Filter teams for bulk selection" value={query}
          onChangeText={setQuery} placeholder="Search teams" style={{ minHeight: 48 }} />
        <Button title="Select all matching" disabled={!hasEnabled} onPress={() => changeMatching(true)} />
        <Button title="Clear matching" disabled={!hasSelectedMatch} onPress={() => changeMatching(false)} />
        <Pressable ref={trigger} accessibilityRole="button"
          accessibilityLabel="Choose individual teams" accessibilityState={{ expanded: open }}
          onPress={() => setOpen(true)} style={{ minHeight: 48, justifyContent: 'center' }}>
          <Text>Choose individual teams</Text>
        </Pressable>
        <Text accessibilityLiveRegion="polite">{notice}</Text>
        <Text>{selectedIds.length} of {limit} teams selected</Text>
      </View>
      <SearchableSelectSheet options={choices} selectedIds={selectedIds}
        onSelectionChange={(next) => { setSelectedIds(next); setNotice(''); }}
        query={query} onQueryChange={setQuery} searchMode="local" filterOption={matches}
        selectionMode="multiple" maxSelected={limit} open={open} onOpenChange={setOpen}
        onRestoreFocus={() => restoreTrigger(trigger.current)}
        labels={{ title: 'Choose teams', search: 'Search teams' }}
        style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }} />
    </View>
  );
}

Use the same GestureHandlerRootView, dependency installation, measured safe-area host, and single keyboard-avoidance owner as the searchable example above. The custom matcher intentionally uses case-insensitive substring search; passing filterOption explicitly overrides the piece's default normalization. If you add accent folding or token matching, apply the identical function to the host scope too.

Chips, limits, and accessible feedback

Rows in multiple mode expose checkbox roles and checked/disabled state. Chips have labeled remove actions and forty-eight-point targets; a disabled known option cannot be removed with a chip either. The field's maxSelected limits individual additions, while bulkSelection enforces the same limit at the host boundary. Validate again on form submission, especially if permissions, available options, or the limit changed after selection.

Keep selectedIds intact when query changes, loading begins, or a remote page omits a chosen record. Previously seen labels are cached; unresolved IDs remain removable raw-ID chips and can be reported with onDataIssue. Supply the missing labels instead of dropping selected IDs. A checked parent in a hierarchical tree needs partial-selection propagation; groups in this component are headings, not parent checkboxes.

Expose text for the limit and accepted count, plus a message when a bulk request is rejected. The example's polite live region supports Android/web status updates; integrate and test iOS announcements if your screen requires spoken feedback for those host controls. Keep focus on the bulk control after a bulk update; move it deliberately when an individual result or chip disappears. Done closes and retains immediate changes, not a deferred Apply transaction. Test keyboard containment, Escape, Android back, screen-reader checked state, locked chips, and restoration to the trigger.

The single-select tutorial

A form select stores a stable value such as two guests; submitting the form is a separate operation. An action menu runs commands such as Share or Remove and should expose action semantics. Do not announce a stored form option as a menu command merely because both controls look like dropdowns. Optional field explanations can use touch help without taking over selection.

The tutorial intentionally presents options in a centered modal instead of positioning a card beneath a field. This avoids clipping by a parent ScrollView and narrow space near a screen edge. The value and dismissal model also apply to an anchored dropdown, which additionally requires anchor measurement, edge collision handling, and safe-area calculations.

Setup

The code targets Expo SDK 58, React Native 0.88.0, Reanimated 4.7.1, and Worklets 0.13.0. Resolve compatible packages through Expo:

npx expo install react-native-reanimated react-native-worklets

Use bunx expo install for Bun projects. Follow the SDK 58 Reanimated setup. Pressable handles discrete selection here; no pan gesture or Gesture Handler dependency is needed. Save the complete file as GuestSelect.tsx outside your Expo Router route tree and render SelectExample from a screen. The host screen owns safe areas.

Build a controlled single select

import type { ComponentRef } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
  AccessibilityInfo, findNodeHandle, Keyboard, Modal, Platform,
  Pressable, ScrollView, StyleSheet, Text, View,
} from 'react-native';
import Animated, { useAnimatedStyle, useReducedMotion, useSharedValue, withTiming } from 'react-native-reanimated';

type Option = { value: string; label: string };
const options: readonly Option[] = [
  { value: 'one', label: '1 guest' },
  { value: 'two', label: '2 guests' },
  { value: 'three', label: '3 guests' },
  { value: 'four', label: '4 guests' },
];

function focusControl(control: ComponentRef<typeof View> | null) {
  if (!control) return;
  if (Platform.OS === 'web') control.focus();
  else {
    const tag = findNodeHandle(control);
    if (tag) AccessibilityInfo.setAccessibilityFocus(tag);
  }
}

function OptionDialog({ value, onChange, onClose }: {
  value: string;
  onChange: (value: string) => void;
  onClose: () => void;
}) {
  const rows = useRef<(ComponentRef<typeof View> | null)[]>([]);
  const cancel = useRef<ComponentRef<typeof View>>(null);
  const active = useRef(Math.max(0, options.findIndex((option) => option.value === value)));
  const progress = useSharedValue(0);
  const reducedMotion = useReducedMotion();

  function focusInitial() {
    focusControl(rows.current[active.current]);
  }

  useEffect(() => {
    progress.set(withTiming(1, { duration: reducedMotion ? 0 : 180 }));
    // Modal.onShow supplies the native accessibility-focus boundary.
    if (Platform.OS !== 'web') return;
    focusControl(rows.current[active.current]);
    const keydown = (event: KeyboardEvent) => {
      const last = options.length;
      const focused = document.activeElement;
      const current = rows.current.findIndex((row) => row === focused as unknown);
      if (event.key === 'Escape') {
        event.preventDefault();
        onClose();
      } else if (event.key === 'Tab') {
        event.preventDefault();
        const at = current < 0 ? last : current;
        const next = (at + (event.shiftKey ? -1 : 1) + last + 1) % (last + 1);
        focusControl(next === last ? cancel.current : rows.current[next]);
      } else if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
        event.preventDefault();
        const start = current < 0 ? active.current : current;
        active.current = event.key === 'Home' ? 0 : event.key === 'End' ? last - 1
          : (start + (event.key === 'ArrowDown' ? 1 : -1) + last) % last;
        focusControl(rows.current[active.current]);
      }
    };
    document.addEventListener('keydown', keydown);
    return () => document.removeEventListener('keydown', keydown);
  }, [onClose, progress, reducedMotion]);

  const motion = useAnimatedStyle(() => ({
    opacity: progress.get(),
    transform: [{ translateY: reducedMotion ? 0 : 8 * (1 - progress.get()) }],
  }));

  return (
    <Modal transparent animationType="none" visible onShow={focusInitial}
      onRequestClose={onClose} onDismiss={onClose}>
      <View style={styles.overlay}>
        <Pressable accessible={false} onPress={onClose} style={StyleSheet.absoluteFill} />
        <Animated.View accessibilityViewIsModal onAccessibilityEscape={onClose}
          style={[styles.dialog, motion]}>
          <Text accessibilityRole="header" style={styles.heading}>Guests</Text>
          <ScrollView keyboardShouldPersistTaps="handled">
            <View accessibilityRole="radiogroup" accessibilityLabel="Number of guests">
              {options.map((option, index) => (
                <Pressable key={option.value} ref={(node) => { rows.current[index] = node; }}
                  accessibilityRole="radio" accessibilityLabel={option.label}
                  accessibilityState={{ checked: value === option.value }}
                  onPress={() => { onChange(option.value); onClose(); }}
                  style={[styles.row, value === option.value && styles.selected]}>
                  <Text>{value === option.value ? '● ' : '○ '}{option.label}</Text>
                </Pressable>
              ))}
            </View>
            <Pressable ref={cancel} accessibilityRole="button" accessibilityLabel="Cancel guest selection"
              onPress={onClose} style={styles.row}><Text>Cancel</Text></Pressable>
          </ScrollView>
        </Animated.View>
      </View>
    </Modal>
  );
}

export function GuestSelect({ value, onChange }: {
  value: string;
  onChange: (value: string) => void;
}) {
  const [open, setOpen] = useState(false);
  const trigger = useRef<ComponentRef<typeof View>>(null);
  const wasOpen = useRef(false);
  const close = useCallback(() => setOpen(false), []);
  const selected = options.find((option) => option.value === value);

  useEffect(() => {
    if (wasOpen.current && !open) {
      const frame = requestAnimationFrame(() => focusControl(trigger.current));
      wasOpen.current = false;
      return () => cancelAnimationFrame(frame);
    }
    wasOpen.current = open;
  }, [open]);

  return (
    <View>
      <Text>Guests</Text>
      <Pressable ref={trigger} accessibilityRole="button" accessibilityLabel="Choose number of guests"
        accessibilityValue={{ text: selected?.label ?? 'Choose an option' }}
        accessibilityState={{ expanded: open }} style={styles.field}
        onPress={() => { Keyboard.dismiss(); setOpen(true); }}>
        <Text>{selected?.label ?? 'Choose an option'} ▾</Text>
      </Pressable>
      {open && <OptionDialog value={value} onChange={onChange} onClose={close} />}
    </View>
  );
}

const styles = StyleSheet.create({
  field: { minHeight: 48, padding: 16, borderWidth: 1, borderColor: '#655475', borderRadius: 12 },
  overlay: { flex: 1, padding: 24, justifyContent: 'center', alignItems: 'center', backgroundColor: '#00000066' },
  dialog: { width: '100%', maxWidth: 400, maxHeight: '80%', padding: 16,
    borderRadius: 20, backgroundColor: '#FAF8FE' },
  heading: { fontSize: 20, padding: 12 },
  row: { minHeight: 48, padding: 14, justifyContent: 'center', borderRadius: 12 },
  selected: { backgroundColor: '#E1D6F5' },
});

export function SelectExample() {
  const [guests, setGuests] = useState('two');
  return (
    <View style={{ flex: 1, padding: 24, justifyContent: 'center' }}>
      <GuestSelect value={guests} onChange={setGuests} />
      <Text>Saved form value: {guests}</Text>
    </View>
  );
}

Store values, not display labels

The parent holds an option value, while the trigger renders the matching label. Labels can be translated without changing saved values. An unknown value displays Choose an option and leaves every radio unchecked rather than silently committing the first item. This example uses a fixed, nonempty list; dynamic lists also need explicit empty, loading, disabled, and removed-selection states.

Selecting a row calls onChange once and closes the dialog. Cancel, outside tap, Escape, Android back, and the accessibility escape gesture close without changing the value. A parent can reject a request: the trigger will still display its accepted value. Dismissing the software keyboard before opening leaves more room for options.

Outside touches and scrolling

The full-screen backdrop and option card are siblings. A tap inside the card cannot bubble to the backdrop and accidentally cancel a selection. The backdrop is hidden from assistive technology because the labeled Cancel control provides the equivalent action. The dialog caps its width at four hundred points and its height at eighty percent of the available layout; its ScrollView keeps options and Cancel reachable with enlarged text.

The React Native Modal supplies the native presentation boundary and onRequestClose handles Android back. On web, the keyboard handler loops Tab through the option buttons and Cancel. Native onShow moves accessibility focus to the initial option; closing requests focus back on the trigger. Test focus restoration with your native presentation stack, particularly when replacing this example's conditional modal with an animated exit. A custom exiting layer must remain mounted and modal until that exit finishes.

Keyboard, screen-reader state, and motion

The trigger has a button role, current-value text, expanded state, and an explicit field label. Options expose radio roles and checked state inside a named group, with forty-eight-point minimum targets. A checked dot also conveys selection visually. These semantics follow the React Native accessibility API.

On web, ArrowUp/ArrowDown move focus, Home/End reach the endpoints, Enter or Space activates the focused Pressable, Escape cancels, and Tab stays within this modal until it closes. Moving focus does not commit a different form value. Native screen readers activate the radio rows directly. Hardware-key handling on native depends on the platform and input system; use the native picker when its built-in keyboard behavior is a requirement, and test VoiceOver and TalkBack on actual devices.

Reanimated animates entry opacity and a small translation through one shared value. Reduced Motion makes entry immediate, and closing is immediate in this teaching example. No per-frame React update or UI-to-RN callback is involved, so scheduleOnRN is unnecessary here. If you add a worklet completion that commits React state, use scheduleOnRN from react-native-worklets rather than an older runOnJS example.

Check selection and cancellation, a parent rejecting a request, an unknown value, a small window, large text, keyboard focus looping, Android back, and repeated open/close. The searchable integration above adds the separate query and ID model needed for filtering and multiple selection.

Compare the catalog components

Morphing Select Menu for a short list

Morphing Select Menu turns the field itself into the option card. One expansion value coordinates height, corner radius, material, chevron, and row positions. A continuous highlight follows the finger, and a selected row contracts into the collapsed value after the selection has rendered. Taps and screen-reader activation offer alternatives to dragging.

Its documented API accepts up to eight options with stable values, labels, optional details and kit icons, an initial index, onChange/onOpenChange callbacks, theme and spring settings, and open/close/hover/select ref methods. It is not a controlled value-prop implementation like this tutorial. The host must wire outside touches to close(), coordinate sibling fields, and keep displaced form content reachable. A configurable upward-shift cap keeps its opening movement within available space.

The published piece requires Skia, Gesture Handler, Reanimated, and Worklets; wrap it in GestureHandlerRootView and initialize CanvasKit before importing it on web. Each published piece is reviewed on physical iPhone and Android devices with Expo Go before release. Check its README and verify the interaction in your form rather than assuming it includes this tutorial's modal keyboard handling, search, or multiple selection.

Searchable Select Sheet for long or grouped options

Searchable Select Sheet supplies grouped, virtualized results, persistent removable chips, selection limits, and loading/error/retry states. Its controlled IDs, query, and visibility are independent. Choose it when the list exceeds a compact inline menu or multiple values belong to the field. It uses expo-image and Gesture Handler with Reanimated and Worklets; it does not require Skia.

Keep focus and accepted values predictable

Cancel, dismiss, and restore focus

The modal tutorial requests one value and closes. Cancel, outside tap, Escape, and Android back leave its accepted value unchanged. Searchable Select Sheet uses immediate selection: dismissing it retains accepted changes as well as query text. If your product needs Apply/Cancel rollback, keep an explicit host draft separate from accepted IDs and restore it when reopening; do not describe Done as an approval transaction the piece does not implement.

The sheet exposes onOpenChange and onRestoreFocus, web Tab containment, and result-key navigation. It is an inline layer, not a native Modal: the host must hide background siblings from native accessibility. Restore focus to a surviving trigger after dismissal and move focus deliberately if filtering removes the focused row. Check selection rejection, disappearing options, retry, large text, a short keyboard viewport, and both screen readers in the final form.

FAQ

How do I select all in a filtered React Native multi-select?

Define the scope explicitly. For local results, merge enabled matching IDs into the accepted collection and retain selections outside that filter. For remote results, label a page-only operation honestly; selecting every server match requires a separate server contract.

What happens when select all exceeds maxSelected?

This guide rejects the whole bulk addition and leaves accepted IDs unchanged. Clear matching still works. Keep the same limit for individual rows, bulk controls, and final form validation.

Is a sectioned multi-select a hierarchical selection tree?

Not necessarily. Searchable Select Sheet groups options under headings. It does not implement parent-child selection propagation or partially checked parent nodes.

What happens to selected values when search changes?

They stay selected until explicitly removed or replaced by the host. A filtered or remote result page is not the complete set of valid selections. Keep labels or resolve missing IDs without discarding them.

Does React Native have an HTML select element?

HTML select belongs to React DOM. For a platform picker in Expo, use the SDK-compatible picker package. For a custom cross-platform form field, store the selected value in the host and expose labeled selectable options.

How do I close a dropdown when someone taps outside?

Put a backdrop behind the option card and call the same close function used by Cancel. Keep the card and backdrop as siblings so option presses cannot bubble into the outside-touch handler. An inline morphing field instead needs an outside-touch handler on its containing screen.

Should selecting an option immediately run an action?

A select changes a form value. If choosing a row executes a command, use an action menu with action semantics and feedback instead. Keep the two behaviors distinct even when their visuals are similar.