Mehdi Davoodi12 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, notonLayout.onLayoutgives you coordinates relative to the parent, which is useless once the panel lives in aModal. 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 withadjustResizeit relayouts the screen, so ameasureInWindowfired in the same tick reports where the trigger was and the panel opens a keyboard-height away from it. Waiting forkeyboardDidHidecosts one listener and removes the whole class of bug. -
The flip decision takes
insets, not rawDimensions. 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. PassuseSafeAreaInsets()in and the flip is decided against the area the user can actually see. -
statusBarTranslucenton 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 withrunOnJSso the exit actually plays.
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.
-
Android does not honour
zIndexacross parents. Elevation determines paint order, and any ancestor withoverflow: 'hidden'clips the panel regardless of either. -
Later siblings paint on top. A dropdown in row one is
covered by row two unless every row's
zIndexdescends down the list. That is exactly whatreact-native-dropdown-picker'szIndexInverseis for in its inline modes, and it is why stacked inline dropdowns need both props set. -
A
FlatListinside aScrollViewtriggers 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-pickerwithlistMode="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