Skip to content
All posts

12 min read

React Native dropdown: the 4 real options, compared

React Native ships no dropdown. The four ways people actually build one, with working code and the Android and ScrollView gotchas.

React Native has no built-in dropdown. There is no <Select> in core and there hasn't been since Picker was deprecated in 2019 and pulled out into a community package, so nearly every dropdown in a React Native app is one of four things: a Modal someone hand-rolled, the community @react-native-picker/picker, a JS library like react-native-dropdown-picker, or a shadcn-style component copied into the repo.

That absence is the whole problem. On the web, <select> handles positioning, keyboard, scroll containment and screen readers for free. On native you get a View and a Modal, and everything else is yours. Below: the four options with the honest trade-off for each, a complete implementation for RN 0.8x with Expo SDK 54 and Reanimated 4, and the two failure modes (stacking order inside a scroller, and the keyboard) behind most of the questions.

Are you building a select or a menu?

Answer this first, because the correct option changes completely and most articles blur the two.

  • A select picks a value and keeps it. Country, currency, category, sort order. The trigger shows the current choice. This is "dropdown list" or "drop down list" in most searches.
  • A menu fires an action and forgets. Share, Rename, Delete. The trigger shows a label or a glyph, never a value. This is "dropdown menu".

If you need a menu, the best answer on native is usually not a JS dropdown at all: it is the platform's own menu, which is zeego wrapping UIMenu on iOS and the Material menu on Android. You get real blur, real haptics, real accessibility and correct edge flipping for free, and it looks like the OS because it is the OS. The same reasoning applies to press-and-hold affordances, which is why our long-press menu is built around the native gesture rather than a floating View.

Everything from here is about selects.

What are the four options?

Approach Cost Styling control Where it hurts
Hand-rolled Modal + FlatList Zero deps, about 200 lines Total Positioning, keyboard and a11y are yours
@react-native-picker/picker One dep, native module Almost none iOS renders a wheel, not a dropdown
react-native-dropdown-picker One dep, JS only High, prop-driven z-index and nesting inside scrollers
Copy-in component CLI, plus NativeWind for some Total, you own the file You own the bugs too

How do you build a React Native dropdown from scratch?

Measure the trigger in window coordinates once the keyboard is out of the way, render the panel inside a transparent Modal so nothing can clip it, flip it above the trigger when there isn't room below, and animate it with Reanimated. That is the entire recipe. Here it is complete and runnable:

import {
  forwardRef,
  useCallback,
  useImperativeHandle,
  useRef,
  useState,
} from 'react';
import {
  FlatList,
  Keyboard,
  Modal,
  Pressable,
  StyleSheet,
  Text,
  useWindowDimensions,
  View,
  type LayoutRectangle,
} from 'react-native';
import Animated, {
  Easing,
  runOnJS,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
} from 'react-native-reanimated';

type Option = { label: string; value: string };

export type SelectHandle = { close: () => void };

type SelectProps = {
  options: Option[];
  value?: string;
  onChange: (next: string) => void;
  placeholder?: string;
  /** Safe area insets, plus the keyboard height if you keep it up. */
  insets?: { top: number; bottom: number };
};

const ROW = 48;
const GAP = 8;
const MAX_PANEL = ROW * 5;

export const Select = forwardRef<SelectHandle, SelectProps>(function Select(
  { options, value, onChange, placeholder = 'Select', insets },
  ref,
) {
  const trigger = useRef<View>(null);
  const [anchor, setAnchor] = useState<LayoutRectangle | null>(null);
  const [mounted, setMounted] = useState(false);
  const progress = useSharedValue(0);
  const { height: windowHeight } = useWindowDimensions();

  const measureAndOpen = useCallback(() => {
    trigger.current?.measureInWindow((x, y, width, height) => {
      setAnchor({ x, y, width, height });
      setMounted(true);
      progress.value = withTiming(1, {
        duration: 160,
        easing: Easing.out(Easing.quad),
      });
    });
  }, [progress]);

  const open = useCallback(() => {
    if (!Keyboard.isVisible()) {
      measureAndOpen();
      return;
    }
    // Dismissing the keyboard relayouts the screen on Android, so measure
    // after it is gone, not in the same tick as the dismiss.
    const sub = Keyboard.addListener('keyboardDidHide', () => {
      sub.remove();
      measureAndOpen();
    });
    Keyboard.dismiss();
  }, [measureAndOpen]);

  const close = useCallback(() => {
    progress.value = withTiming(0, { duration: 120 }, (finished) => {
      if (finished) runOnJS(setMounted)(false);
    });
  }, [progress]);

  useImperativeHandle(ref, () => ({ close }), [close]);

  const insetTop = insets?.top ?? 0;
  const insetBottom = insets?.bottom ?? 0;
  const wanted = Math.min(options.length * ROW, MAX_PANEL);
  const spaceBelow = anchor
    ? windowHeight - insetBottom - (anchor.y + anchor.height) - GAP * 2
    : 0;
  const spaceAbove = anchor ? anchor.y - insetTop - GAP * 2 : 0;
  const dropUp = spaceBelow < wanted && spaceAbove > spaceBelow;
  const panelHeight = Math.min(
    wanted,
    Math.max(dropUp ? spaceAbove : spaceBelow, ROW),
  );

  const panelStyle = useAnimatedStyle(() => ({
    opacity: progress.value,
    transform: [
      { translateY: (1 - progress.value) * (dropUp ? 8 : -8) },
      { scale: 0.96 + progress.value * 0.04 },
    ],
  }));
  const backdropStyle = useAnimatedStyle(() => ({ opacity: progress.value }));

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

  return (
    <>
      <Pressable
        ref={trigger}
        onPress={open}
        style={styles.trigger}
        accessibilityRole="combobox"
        accessibilityLabel={placeholder}
        accessibilityState={{ expanded: mounted }}
        accessibilityValue={{ text: selected?.label ?? 'nothing selected' }}
      >
        <Text>{selected?.label ?? placeholder}</Text>
      </Pressable>

      <Modal
        visible={mounted}
        transparent
        animationType="none"
        statusBarTranslucent
        onRequestClose={close}
      >
        <Pressable style={StyleSheet.absoluteFill} onPress={close}>
          <Animated.View
            style={[StyleSheet.absoluteFill, styles.backdrop, backdropStyle]}
          />
        </Pressable>

        {anchor ? (
          <Animated.View
            accessibilityViewIsModal
            style={[
              styles.panel,
              {
                left: anchor.x,
                width: anchor.width,
                height: panelHeight,
                top: dropUp
                  ? anchor.y - panelHeight - GAP
                  : anchor.y + anchor.height + GAP,
                transformOrigin: dropUp ? 'bottom center' : 'top center',
              },
              panelStyle,
            ]}
          >
            <FlatList
              data={options}
              keyExtractor={(item) => item.value}
              bounces={false}
              renderItem={({ item }) => (
                <Pressable
                  style={styles.row}
                  onPress={() => {
                    onChange(item.value);
                    close();
                  }}
                  accessibilityRole="button"
                  accessibilityState={{ selected: item.value === value }}
                >
                  <Text>{item.label}</Text>
                </Pressable>
              )}
            />
          </Animated.View>
        ) : null}
      </Modal>
    </>
  );
});

const styles = StyleSheet.create({
  trigger: {
    height: ROW,
    justifyContent: 'center',
    paddingHorizontal: 14,
    borderRadius: 12,
    borderWidth: 1,
    borderColor: '#e5e4dc',
    backgroundColor: '#fff',
  },
  backdrop: { backgroundColor: 'rgba(0,0,0,0.12)' },
  panel: {
    position: 'absolute',
    borderRadius: 12,
    borderWidth: 1,
    borderColor: '#e5e4dc',
    backgroundColor: '#fff',
    overflow: 'hidden',
    elevation: 8,
    shadowColor: '#000',
    shadowOpacity: 0.12,
    shadowRadius: 20,
    shadowOffset: { width: 0, height: 8 },
  },
  row: { height: ROW, justifyContent: 'center', paddingHorizontal: 14 },
});

Five details in there are doing more work than they look like they are.

  • measureInWindow, not onLayout. onLayout gives you coordinates relative to the parent, which is useless once the panel lives in a Modal. Window coordinates are the only shared space between the two.
  • Measure after the keyboard is gone, not before. Keyboard.dismiss() is the right call, but on Android with adjustResize it relayouts the screen, so a measureInWindow fired in the same tick reports where the trigger was and the panel opens a keyboard-height away from it. Waiting for keyboardDidHide costs one listener and removes the whole class of bug.
  • The flip decision takes insets, not raw Dimensions. Window height includes the Android navigation bar and the iOS home indicator, so a trigger near the bottom of the screen thinks it has room it does not have and the panel opens under the system UI. Pass useSafeAreaInsets() in and the flip is decided against the area the user can actually see.
  • statusBarTranslucent on Android, when you are not already edge to edge. On a classic Android setup the modal's window starts below the status bar while your measurement included it, so the panel sits a status-bar-height too low. That is the single most common "it's fine on iOS, it's off on Android" bug in hand-rolled dropdowns. On Expo SDK 54, where Android edge to edge is the default and the app window already extends behind the status bar, the prop is close to a no-op. Leave it set anyway, because it costs nothing and it keeps the component right in both configurations.
  • animationType="none". You want Reanimated to own the motion, not the platform modal transition. Mount the modal instantly, animate the panel, and unmount on the timing callback with runOnJS so the exit actually plays.
Version note

Reanimated 4 runs on the New Architecture only. Expo SDK 54 defaults to it, so a fresh npx create-expo-app is fine. If your app is still on the old architecture you are on Reanimated 3, where every API used above behaves the same, so the code is unchanged.

When should you use @react-native-picker/picker?

When the value matters more than the look. It is the community-maintained extraction of the old core Picker, it renders the real platform control, and it inherits platform accessibility, rotation and dark mode without you writing a line.

The honest catch: on iOS it is not a dropdown. It renders a UIPickerView, the spinning wheel, inline in your layout at a fixed height of roughly 216pt. If you want an iOS dropdown you have to put the wheel inside your own sheet or modal and drive it yourself. On Android mode="dropdown" anchors a real dropdown to the field and mode="dialog" opens a centered list, so the two platforms end up looking nothing alike unless you intervene.

Styling is close to nonexistent: itemStyle is iOS only, and font and color control on Android is thin. Pick it for a settings screen, a country field, a timezone field. Do not pick it if the field is part of your brand. Worth watching, though, is a fifth option in the making: @expo/ui now exposes SwiftUI and Jetpack Compose primitives, including a native picker, from JS. Still early and the API is moving, but it is the direction of travel for "give me the real native control, styled properly."

Is react-native-dropdown-picker still the right choice?

It is the package at the top of nearly every search for this, and it does the job: searchable, multi-select, badges, custom renderers, all prop-driven. Two things surprise people.

First, the API is a controlled triple where you pass the setters, not just the values. Second, it renders inline by default, which is where the z-index complaints come from. The second one has a one-prop fix:

import { useState } from 'react';
import DropDownPicker from 'react-native-dropdown-picker';

export function Fruit() {
  const [open, setOpen] = useState(false);
  const [value, setValue] = useState<string | null>(null);
  const [items, setItems] = useState([
    { label: 'Apple', value: 'apple' },
    { label: 'Banana', value: 'banana' },
  ]);

  return (
    <DropDownPicker
      open={open}
      value={value}
      items={items}
      setOpen={setOpen}
      setValue={setValue}
      setItems={setItems}
      listMode="MODAL"
    />
  );
}

listMode="MODAL" lifts the list into its own window, so there is no inline stacking context left to fight. That is also why the snippet sets nothing else: dropDownDirection is already "AUTO" by default, and zIndex and zIndexInverse only do anything in the inline modes, where the panel really is a sibling of the rows around it. If you want to stay inline inside a ScrollView, listMode="SCROLLVIEW" is the documented fix for the nested virtualized list warning, and that is the case where you set both zIndex props on every stacked picker. If the library's maintenance pace worries you, react-native-element-dropdown is the usual alternative: smaller surface area, renders through a modal by default, search built in.

Why does my dropdown render behind everything inside a ScrollView?

Because an inline dropdown panel is a child of the scroll content, and three separate rules conspire against it.

  1. Android does not honour zIndex across parents. Elevation determines paint order, and any ancestor with overflow: 'hidden' clips the panel regardless of either.
  2. Later siblings paint on top. A dropdown in row one is covered by row two unless every row's zIndex descends down the list. That is exactly what react-native-dropdown-picker's zIndexInverse is for in its inline modes, and it is why stacked inline dropdowns need both props set.
  3. A FlatList inside a ScrollView triggers the "VirtualizedLists should never be nested" warning and quietly breaks virtualization.

The reliable fix is to stop fighting stacking order and render the panel in a Modal, which is what the code above does. One consequence to handle: a modal panel does not scroll with the page, so if the user scrolls while it is open, the panel stays put and the trigger slides away. That is what the SelectHandle ref exists for. Hold one, and close the panel from the scroller.

import { useRef, useState } from 'react';
import { ScrollView } from 'react-native';
import { Select, type SelectHandle } from './Select';

export function SortField() {
  const select = useRef<SelectHandle>(null);
  const [sort, setSort] = useState('new');

  return (
    <ScrollView onScrollBeginDrag={() => select.current?.close()}>
      <Select
        ref={select}
        options={[
          { label: 'Newest', value: 'new' },
          { label: 'Oldest', value: 'old' },
        ]}
        value={sort}
        onChange={setSort}
      />
    </ScrollView>
  );
}

One prop on the scroller, one line in the component, and it is what native pickers do anyway.

How do you handle the keyboard?

Call Keyboard.dismiss() before you open, and take the measurement after it is actually gone. It is unglamorous and it is right almost every time: the user has finished typing in the field above and is now choosing a value, so the keyboard has no job. The ordering is the part people get wrong, and it produces a panel that lands a keyboard-height off the trigger on Android.

If you genuinely need it up, note that KeyboardAvoidingView will not help you. It shifts its children, and an absolutely positioned panel in a separate modal window is not one of them. Instead read the keyboard height with Reanimated's useAnimatedKeyboard and add it to the bottom inset you hand the component, so the space-below calculation shrinks and the panel flips up on its own. Work from a visible area you compute yourself rather than trusting a raw window height, because what "window height" means with the keyboard up differs between platforms and edge-to-edge settings.

What does an accessible dropdown actually need?

This is the part hand-rolled dropdowns skip, and it is eight props. The trigger needs accessibilityRole="combobox", an accessibilityLabel naming the field, accessibilityState={{ expanded }} so a screen reader announces open and closed, and accessibilityValue carrying the current choice. Each row needs accessibilityRole="button" and accessibilityState={{ selected }}. The panel needs accessibilityViewIsModal on iOS so VoiceOver does not read the screen behind it, and the Modal needs onRequestClose so the Android back button dismisses it instead of leaving the app.

On the row role: React Native has no option, and menuitem announces a menu, which is the wrong promise for a control that keeps a value rather than firing an action. A button that reports its own selected state is the closest honest mapping the platform gives you.

Every one of those is free with @react-native-picker/picker and with native menus. That is the strongest argument for them, and it is the one comparison posts leave out.

What about copy-in components?

The fourth option is the shadcn pattern: a CLI writes the component's source into your repo and from then on it is your file. react-native-reusables is the best-known one here, and its dropdown menu is a good piece of work. Go in knowing two things: it is built on NativeWind, so adopting it means adopting a styling system, and once the file is yours you inherit its bugs. No upstream patch is coming. That trade is worth making when the control carries your design language, which is the same reason our drops ship as plain Reanimated source rather than as a package: a dropdown should inherit your radii and your easing, not import someone else's.

So which one should you use?

  • Two to four options? Do not use a dropdown. A segmented control shows every choice at once and costs one tap instead of two, which is the reasoning behind our nested switch.
  • Twenty or more options, or anything searchable? A bottom sheet beats a dropdown on a phone: more room, a real search field, thumb reachable. The attachment sheet pattern is the same mechanics.
  • Actions, not values? Native menus via zeego.
  • A settings-shaped value where looks don't matter? @react-native-picker/picker.
  • You need it working this afternoon? react-native-dropdown-picker with listMode="MODAL".
  • It's part of your design language? Hand-roll it. The code above is the whole thing, and owning about 200 lines you understand is cheaper than fighting someone else's prop surface for a year.

A dropdown is one of a family of selection controls, and the others are written up the same way, free and copy-paste: switch selector, radio button and action sheet.

Does React Native have a built-in dropdown?

No. React Native core has no Select or Dropdown component. The old Picker was deprecated in 2019, moved out to the community package @react-native-picker/picker, and later removed from core entirely. Every dropdown in a React Native app is either that package, a third-party JS library, or a component someone built from a Modal and a list.

What is the best React Native dropdown library?

There is no single best one, because the three candidates solve different problems. @react-native-picker/picker gives you the real platform control and free accessibility but almost no styling, and on iOS it renders a wheel rather than a dropdown. react-native-dropdown-picker is the fastest way to a styled, searchable, multi-select dropdown but needs care with stacking order. react-native-element-dropdown is a lighter alternative that renders through a modal by default. If the control has to match a design system exactly, hand-rolling a Modal plus FlatList is roughly 200 lines and removes the dependency entirely.

Why does my React Native dropdown appear behind other components?

Because an inline dropdown is a child of its parent's layout. On Android, zIndex does not lift a view out of its parent, elevation controls paint order, and any ancestor with overflow: 'hidden' clips the panel outright. On both platforms, siblings rendered after the dropdown paint on top of it. The reliable fix is to render the panel inside a transparent Modal, positioned with coordinates from measureInWindow, so it is no longer part of that stacking context at all.

How do I use a dropdown inside a ScrollView or FlatList?

Render the dropdown panel in a Modal rather than inline, and close it when the user starts scrolling by handling onScrollBeginDrag, since a modal panel does not move with the content. That means the dropdown needs a way to be closed from outside, so expose a close() method on a ref. If you are using react-native-dropdown-picker, listMode="MODAL" sidesteps stacking entirely, while listMode="SCROLLVIEW" keeps it inline and avoids the nested VirtualizedList warning, and that inline case is where you set both zIndex and zIndexInverse on every stacked picker.

Should I use a dropdown or a bottom sheet on mobile?

Use a dropdown for short lists of up to about ten options where the panel can sit next to its trigger. Use a bottom sheet once the list is long enough to need scrolling or a search field, because a sheet gives you more room, keeps the list in the thumb zone, and does not have to be positioned against a trigger. For two to four options, use neither: a segmented control shows every choice at once and takes one tap instead of two.

The takeaway

The missing <select> is not an oversight you should paper over with the first npm result. Decide whether you are picking a value or firing an action, decide whether the control belongs to your brand or to the operating system, and the choice makes itself. Then render the panel in a Modal, measure in window coordinates once the keyboard is down, and decide the flip against the area the user can actually see, and you will have skipped the bugs everyone else files.

React NativeExpoReanimatedDropdownSelectUI