@shaquillehinds/react-native-dropdown-selector
v0.0.8
Published
A simple dropdown selector for react native that just works.
Maintainers
Readme
@shaquillehinds/react-native-dropdown-selector
An animated dropdown selector for React Native that measures its position on screen and expands upward or downward depending on available space. Every part is styleable, and the whole thing is generic over your value type.
Contents
- Installation
- Quick start
- Items and values
- Direction and size
- Layout modes
- Styling
- Custom components
- Open and close behaviour
- Animation
- API reference
- Recipes
- Troubleshooting
- AI agent rules
Installation
npm install @shaquillehinds/react-native-dropdown-selectorPeer dependencies:
npm install react-native-gesture-handler react-native-reanimated react-native-svg@shaquillehinds/react-native-essentials provides the layout and animation
primitives.
Import react-native-gesture-handler at the top of your entry file and wrap your
app in GestureHandlerRootView — the dropdown list scrolls with a gesture-handler
ScrollView.
Quick start
import { useState } from 'react';
import { DropDownSelector } from '@shaquillehinds/react-native-dropdown-selector';
const FRUITS = [
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
{ label: 'Orange', value: 'orange' },
];
function FruitPicker() {
const [fruit, setFruit] = useState('apple');
return (
<DropDownSelector
items={FRUITS}
selectedItem={fruit}
onSelect={setFruit}
placeholder="Select a fruit"
/>
);
}The component is fully controlled — there is no internal selection state and no
default value. items, selectedItem, onSelect and placeholder are all
required.
T is inferred from items and selectedItem; annotate explicitly
(<DropDownSelector<number> …>) only where inference fails.
Keep items referentially stable — a module constant or useMemo — since it
feeds a memoised label lookup.
Items and values
type DropDownItem<T> = { label: string; value: T };Values are compared with ===
The displayed label and the selected indicator are both resolved by
item.value === selectedItem. Use primitives — string or number.
If you use an object or array as a value, the same reference must appear in both
items and selectedItem. A fresh object literal on each render silently breaks
the match: the placeholder shows instead of the label, and no item is marked
selected.
// ❌ new references every render — never matches
items={users.map(u => ({ label: u.name, value: { id: u.id } }))}
// ✅ select by id and look the object up yourself
items={users.map(u => ({ label: u.name, value: u.id }))}
selectedItem={selectedUserId}
onSelect={setSelectedUserId}Labels must be unique
Items are keyed by label, not by value. Two items sharing a label cause a React
key collision. Disambiguate the label text rather than relying on distinct values.
Showing the placeholder
There is no "unselected" sentinel. Pass a selectedItem that matches nothing
('', null, -1) and the placeholder shows.
Direction and size
Automatic direction
Leave expandDirection unset and the component measures its position on screen,
compares the distance to the bottom against the dropdown's maximum height, and
expands upward when there isn't room below. The chevron animates to match.
Forcing a direction
expandDirection = 'up'; // always upward
expandDirection = 'down'; // always downwardexpandDistance
Maximum dropdown height in pixels. Defaults to 30% of screen height in column layout and 20% in row layout. Content taller than this scrolls.
This value also feeds the direction decision — a larger expandDistance makes
upward expansion more likely, since more space is required below.
Layout modes
dropDownItemsLayout="column" (default)
Items stack vertically and the list scrolls vertically past expandDistance. The
selected item shows a radio indicator on the right.
dropDownItemsLayout="row"
Items lay out horizontally and scroll horizontally; vertical scrolling is disabled. The selected item shows a small dot above its label. Default max height drops to 20% of screen height.
Suited to short labels — filter chips, sort options, day pickers.
Styling
Each visual part has its own prop rather than a single style object:
| Prop | Targets | Defaults worth knowing |
| ---------------------------------- | ------------------ | ------------------------------------------------------------------ |
| containerProps | Root wrapper | zIndex: maxZIndex |
| dropdownButtonProps | The button | White, borderRadius: 'medium', padding [1, 5], always shadowed |
| dropdownButtonTextProps | Button label | 1 line, maxWidth: '90%' |
| dropdownButtonIconContainerProps | Chevron wrapper | – |
| dropdownButtonIconProps | Built-in chevron | { size?, color? }, black |
| dropdownScrollViewProps | Scroll container | – |
| dropdownContentContainerProps | Item list | #FAFAFA, borderRadius: 'soft' |
| dropdownItemProps | Each item row | – |
| dropdownItemTextProps | Each item label | 1 line |
| DropdownSelectedItemIconColor | Selected indicator | – |
These take react-native-essentials layout props — backgroundColor,
borderRadius: 'soft' | 'medium' | 'large', padding: [vertical, horizontal],
margin, and so on — not raw style objects. Most also accept style, which
merges last.
<DropDownSelector
items={items}
selectedItem={value}
onSelect={setValue}
placeholder="Select option"
dropdownButtonProps={{
backgroundColor: '#1E1E1E',
borderRadius: 'large',
padding: [3, 5],
}}
dropdownButtonTextProps={{ color: '#fff', fontSize: 16, fontWeight: '500' }}
dropdownContentContainerProps={{ backgroundColor: '#2A2A2A' }}
dropdownItemProps={{ padding: [3, 5], backgroundColor: '#2A2A2A' }}
dropdownItemTextProps={{ color: '#fff', fontSize: 15 }}
DropdownSelectedItemIconColor="#007AFF"
/>Don't override padding on dropdownContentContainerProps
The list carries a direction-dependent padding — top when expanding down, bottom
when expanding up — that keeps it clear of the button. Overriding padding
removes that clearance and the list sits on top of the button.
Space the items with dropdownItemProps.padding, or set margin on the
container, instead.
disableShadow covers the list only
The button is shadowed unconditionally. To remove that, override through
dropdownButtonProps.style, which merges after the default.
On Android the list shadow is an animated overlay rather than a static style;
disableShadow removes both paths.
Row layout ignores some container props
In 'row' layout, flexDirection, scrollable and horizontal are applied
after your dropdownContentContainerProps and can't be overridden.
Custom components
DropdownItemSelectedIcon
Replaces just the selected indicator, leaving the row's press handling and layout intact. This is the lightest way to customize.
DropdownItemSelectedIcon={({ item }) => <CheckIcon color="#007AFF" />}DropdownButtonIcon
Replaces the chevron.
DropdownButtonIcon={({ isOpen, expandDirection }) => (
<Chevron
style={{ transform: [{ rotate: isOpen ? '180deg' : '0deg' }] }}
pointing={expandDirection}
/>
)}isOpen reflects the dropdown state. expandDirection is 'up' or 'down' and
matches the direction the list actually expands, whether forced or automatic.
The built-in chevron doesn't render until the component has measured its position, so there's one frame without it. A custom icon renders immediately — worth using if that frame is noticeable in your layout.
DropdownItemComponent
Replaces the entire item row. Your component receives four props:
| Prop | Type | Description |
| ------------ | ----------------- | ------------------------------------------------------------------ |
| item | DropDownItem<T> | This row's label and value |
| isSelected | boolean | item.value === selectedItem |
| onPress | () => void | Selects the item, fires onDropdownItemPress, closes the dropdown |
| close | () => void | Closes without selecting |
DropdownItemComponent={({ item, isSelected, onPress }) => (
<Pressable onPress={onPress} style={styles.row}>
<Image source={{ uri: item.icon }} style={styles.icon} />
<Text style={[styles.label, isSelected && styles.selected]}>{item.label}</Text>
{isSelected && <CheckIcon />}
</Pressable>
)}Because your component replaces the touchable wrapper, it has to call onPress
itself. An item that never calls it renders correctly and does nothing.
Don't call onSelect directly from inside a custom item — onPress already does
that and closes the dropdown as well.
For anything short of a full row redesign, prefer dropdownItemProps,
dropdownItemTextProps and DropdownItemSelectedIcon.
Open and close behaviour
- The button toggles the dropdown.
- Selecting an item closes it — via the built-in row, or via
onPressin a custom one. - Tapping outside does not close it. There's no dismissing backdrop; taps pass through to the content behind.
isDisabled disables the button; it doesn't close an already-open dropdown.
There is no ref or imperative API — the dropdown can't be opened or closed programmatically.
Callbacks
onOpen={() => analytics.track('dropdown_opened')}
onClose={() => analytics.track('dropdown_closed')}
onDropdownItemPress={item => console.log('picked', item.label)}onOpen fires when the list mounts, onClose when it unmounts — after the
unmount delay. onDropdownItemPress fires right after onSelect.
Animation
expandAnimationConfig={{ type: 'spring', stiffness: 200, damping: 20, useNativeDriver: true }}
expandAnimationConfig={{ type: 'timing', duration: 250, useNativeDriver: true }}This is React Native's Animated config shape, not Reanimated's. Spring takes
stiffness/damping or speed/bounciness; timing takes duration and
easing. Omit toValue — it's supplied internally.
Always include useNativeDriver. The animation drives opacity and translateY,
so true is correct.
The default is a spring with stiffness: 200, damping: 20.
Unmount delay
The list stays mounted briefly after closing so the exit animation can play. The delay resolves as:
- timing
expandAnimationConfig→ itsduration(falling back to 300) - otherwise →
unMountDelayInMilliSeconds(falling back to 300)
So a timing config silently ignores unMountDelayInMilliSeconds. Pair
unMountDelayInMilliSeconds with a spring config, or just set the timing
duration.
If the dropdown snaps away before finishing its exit, the delay is shorter than the animation.
API reference
| Prop | Type | Required | Description |
| ---------------------------------- | ------------------------------------------------------- | -------- | ---------------------------------------------------- |
| items | DropDownItem<T>[] | ✅ | { label, value }; labels must be unique |
| selectedItem | T | ✅ | Matched against item.value with === |
| onSelect | (value: T) => void | ✅ | Called on selection |
| placeholder | string | ✅ | Shown when no item matches |
| onOpen | () => void | | List mounted |
| onClose | () => void | | List unmounted, after the delay |
| onDropdownItemPress | (item: DropDownItem<T>) => void | | Fires after onSelect |
| isDisabled | boolean | | Disables the button |
| disableShadow | boolean | | List shadow only |
| dropDownItemsLayout | 'column' \| 'row' | | Default 'column' |
| expandDirection | 'up' \| 'down' | | Omit for automatic |
| expandDistance | number | | Max height in px; default 30% / 20% of screen height |
| expandAnimationConfig | RN Animated timing or spring config + type | | Overrides the default spring |
| unMountDelayInMilliSeconds | number | | Default 300; ignored with a timing config |
| containerProps | LayoutProps | | Root wrapper |
| dropdownButtonProps | RNPressableLayoutProps | | The button |
| dropdownButtonTextProps | BaseTextProps | | Button label |
| dropdownButtonIconContainerProps | LayoutProps | | Chevron wrapper |
| dropdownButtonIconProps | { size?: number; color?: string } | | Built-in chevron |
| DropdownButtonIcon | ({ isOpen, expandDirection }) => JSX.Element | | Replaces the chevron |
| dropdownScrollViewProps | ScrollViewProps | | Scroll container |
| dropdownContentContainerProps | LayoutProps | | Item list; don't override padding |
| DropdownItemComponent | ({ item, isSelected, onPress, close }) => JSX.Element | | Replaces the row; must call onPress |
| dropdownItemProps | TouchableLayoutProps | | Item row |
| dropdownItemTextProps | BaseTextProps | | Item label |
| DropdownItemSelectedIcon | ({ item }) => JSX.Element | | Replaces the indicator only |
| DropdownSelectedItemIconColor | string | | Built-in indicator colour |
Types
type DropDownItem<T> = { label: string; value: T };
type DropdownButtonIconProps = { size?: number; color?: string };
type ExpandAnimationConfig =
| (Omit<Animated.TimingAnimationConfig, 'toValue'> & { type: 'timing' })
| (Omit<Animated.SpringAnimationConfig, 'toValue'> & { type: 'spring' });Exports
import DropDownSelector, {
DropDownSelector,
} from '@shaquillehinds/react-native-dropdown-selector';
import type {
DropDownItem,
DropDownItemValue,
DropDownSelectorProps,
DropdownButtonIconProps,
} from '@shaquillehinds/react-native-dropdown-selector';Named and default export are the same component.
Recipes
Selecting by id
const [userId, setUserId] = useState<number>(1);
const items = useMemo(
() => users.map((u) => ({ label: u.name, value: u.id })),
[users]
);
<DropDownSelector
items={items}
selectedItem={userId}
onSelect={setUserId}
placeholder="Select a user"
/>;The selected user object is users.find(u => u.id === userId). Keep objects out
of value so === keeps working.
Filter chips
<DropDownSelector
items={SORT_OPTIONS}
selectedItem={sort}
onSelect={setSort}
placeholder="Sort"
dropDownItemsLayout="row"
expandDistance={120}
dropdownItemTextProps={{ fontSize: 14 }}
/>Rich item rows
<DropDownSelector
items={countries}
selectedItem={countryCode}
onSelect={setCountryCode}
placeholder="Select a country"
DropdownItemComponent={({ item, isSelected, onPress }) => (
<Pressable onPress={onPress} style={styles.countryRow}>
<Flag code={item.value} />
<Text style={[styles.name, isSelected && styles.selectedName]}>
{item.label}
</Text>
{isSelected && <CheckIcon color="#007AFF" />}
</Pressable>
)}
/>Dark theme
<DropDownSelector
items={items}
selectedItem={value}
onSelect={setValue}
placeholder="Select option"
dropdownButtonProps={{
backgroundColor: '#1E1E1E',
borderRadius: 'large',
borderWidth: 1,
borderColor: '#333',
}}
dropdownButtonTextProps={{ color: '#fff' }}
dropdownButtonIconProps={{ color: '#fff' }}
dropdownContentContainerProps={{ backgroundColor: '#2A2A2A' }}
dropdownItemProps={{ backgroundColor: '#2A2A2A' }}
dropdownItemTextProps={{ color: '#fff' }}
DropdownSelectedItemIconColor="#007AFF"
/>Inside a scroll view
Position is measured on layout and re-read when the button is pressed, so
scrolling between renders is handled. If the dropdown consistently picks the wrong
direction inside a container that shifts after mount, force it with
expandDirection.
Troubleshooting
The label never shows — always the placeholder. selectedItem isn't matching
any item.value under ===. Usually an object or array value with a fresh
reference each render. Switch to a primitive.
Nothing is marked selected. Same cause as above.
Items render oddly or reorder. Duplicate label values — items are keyed by
label.
A custom item doesn't select or close. Your DropdownItemComponent isn't
calling the onPress it receives.
Tapping outside doesn't dismiss it. Expected; there's no dismissing backdrop.
The list covers the button. padding was overridden on
dropdownContentContainerProps, removing the direction-dependent clearance.
The exit animation is cut short. The unmount delay is shorter than the
animation. Raise unMountDelayInMilliSeconds, or use a timing config whose
duration covers it.
unMountDelayInMilliSeconds seems ignored. It is, when expandAnimationConfig
is a timing config — the config's duration wins.
Items don't scroll. Confirm react-native-gesture-handler is imported in your
entry file and GestureHandlerRootView wraps the app. In 'row' layout vertical
scrolling is disabled by design.
The button shadow won't go away. disableShadow only covers the list.
Override through dropdownButtonProps.style.
AI agent rules
The package ships a rules file written for AI coding agents (Claude Code, Cursor,
Codex, Copilot, etc.) at rules/AGENT_RULES.md. It spells out the behaviour that
isn't visible from the types — that values are matched with === and items keyed
by label, that a custom item component has to call its own onPress, that tapping
outside doesn't close the dropdown — and lists every prop so it cannot invent
APIs. Point your agent at it with any of the following.
Copy it into your project (recommended)
npx rndd-rules # writes ./AGENTS.md
npx rndd-rules cursor # writes ./.cursor/rules/react-native-dropdown-selector.mdc (alwaysApply)
npx rndd-rules claude # writes ./.claude/rules/react-native-dropdown-selector.md
npx rndd-rules codex # writes ./.codex/rules/react-native-dropdown-selector.md
npx rndd-rules copilot # writes ./.github/instructions/react-native-dropdown-selector.instructions.md
npx rndd-rules windsurf # writes ./.windsurf/rules/react-native-dropdown-selector.md
npx rndd-rules docs/ai/dropdown.md # custom pathAdd --force to overwrite an existing file. --print writes the rules to stdout
instead of to disk. Re-run after upgrading the package to pick up rule changes.
Reference it without copying (Claude Code)
CLAUDE.md supports @path imports, so a single line keeps the rules in sync with
the installed version:
# CLAUDE.md
@node_modules/@shaquillehinds/react-native-dropdown-selector/rules/AGENT_RULES.mdReference it from a generic AGENTS.md
Before adding or customizing a dropdown, read and follow
node_modules/@shaquillehinds/react-native-dropdown-selector/rules/AGENT_RULES.md.License
MIT © Shaquille Hinds
