Animated Dropdown Picker

A customizable, animated dropdown picker component that allows users to select an item from a list, with support for custom item rendering and controlled open/close state.

react-nativedropdownpickerselectanimationreanimatedui-componentform-input

Installation

Step 1: Install Dependencies

npx expo install @expo/vector-icons/Ionicons

Step 2: Create and copy DropdownPicker.tsx

DropdownPicker.tsx
import React, { useEffect } from "react";
import {
  View,
  TouchableOpacity,
  Text,
  FlatList,
  StyleSheet,
  StyleProp,
  ViewStyle,
  TextStyle,
  ListRenderItem,
  useWindowDimensions,
  PixelRatio,
} from "react-native";
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withTiming,
  Easing,
  interpolate,
  Extrapolation,
  ReduceMotion,
} from "react-native-reanimated";

const INPUT_HEIGHT = 60;

export type DropdownPickerItem = {
  id: string | number;
  label: string;
  value: any;
  [key: string]: any;
}

type DropdownPickerProps<TItem extends DropdownPickerItem> = {
  isOpen: boolean;
  setIsOpen: (open: boolean) => void;
  data: TItem[];
  onItemSelected: (item: TItem) => void;
  inputHeight?: number;
  renderItem?: ListRenderItem<TItem>;
  placeholder?: string;
  selectedValue?: TItem | null;
  triggerContainerStyle?: StyleProp<ViewStyle>;
  triggerTextStyle?: StyleProp<TextStyle>;
  triggerIcon?: React.ReactElement;
  dropdownBackgroundColor?: string;
  dropdownMaxHeight?: number; // User-defined max height for the list itself
  dropdownItemTextStyle?: StyleProp<TextStyle>;
  dropdownSeparatorColor?: string;
  animationDuration?: number;
  reduceMotion?: "never" | "always" | "system";
  containerStyle?: StyleProp<ViewStyle>; // Overall container style
  itemHeight?: number; // Optional: if you know the exact height of each item
};

const DropdownPicker = <TItem extends DropdownPickerItem>({
  data,
  onItemSelected,
  isOpen,
  setIsOpen,
  renderItem,
  placeholder = "Select an item...",
  selectedValue,
  triggerContainerStyle,
  triggerTextStyle,
  triggerIcon,
  dropdownBackgroundColor = "#FFFFFF",
  dropdownMaxHeight,
  dropdownItemTextStyle,
  dropdownSeparatorColor = "#E0E0E0",
  animationDuration = 300,
  reduceMotion = "system",
  containerStyle,
  itemHeight = 45, // Default estimated item height
}: DropdownPickerProps<TItem>) => {
  const animationProgress = useSharedValue(0);
  const fontScale = PixelRatio.getFontScale();
  const { height: windowHeight, width: windowWidth } = useWindowDimensions();
  // Calculate default max height for the dropdown list part
  const defaultListMaxHeight = dropdownMaxHeight || windowHeight * 0.4;
  // Calculate the actual height the list content would take
  const listContentActualHeight = data.length * itemHeight;
  //take the min height this is useful for when the list is small
  const listVisibleHeight = Math.min(defaultListMaxHeight, listContentActualHeight);

  const motion =
  	reduceMotion === "never"
  		? ReduceMotion.Never
  		: reduceMotion === "always"
  			? ReduceMotion.Always
  			: ReduceMotion.System;

  const TIMING_CONFIG = {
  	duration: animationDuration,
  	easing: Easing.bezier(0.25, 0.1, 0.25, 1),
  	reduceMotion: motion,
  };

  useEffect(() => {
      if (isOpen) {
          // Parent wants to open it
          animationProgress.value = withTiming(1, TIMING_CONFIG);
      } else {
          // Parent wants to close it
          animationProgress.value = withTiming(0, TIMING_CONFIG);
      }
  }, [isOpen, animationProgress, TIMING_CONFIG]); 

  const toggleDropdown = () => {
      setIsOpen(!isOpen); 
  };

  const handleItemPress = (item: TItem) => {
  	onItemSelected(item);
  };

  const animatedOuterContainerStyle = useAnimatedStyle(() => {
  	// The outer container's height animates from INPUT_HEIGHT to INPUT_HEIGHT + listVisibleHeight
  	const height = interpolate(
  		animationProgress.value,
  		[0, 1],
  		[INPUT_HEIGHT * fontScale, (INPUT_HEIGHT * fontScale) + (data.length > 0 ? listVisibleHeight : 0)], // No extra height if no data
  		Extrapolation.CLAMP
  	);
  	return { height };
  });

  const animatedDropdownContentStyle = useAnimatedStyle(() => {
  	const opacity = interpolate(
  		animationProgress.value,
  		[0, 0.5, 1], // Start fading in a bit later
  		[0, 0, 1],
  		Extrapolation.CLAMP
  	);
  	const translateY = interpolate(
  		animationProgress.value,
  		[0, 1],
  		[-20, 0], // Slide down effect
  		Extrapolation.CLAMP
  	);
  	return { opacity, transform: [{ translateY }] };
  });

  const animatedIconStyle = useAnimatedStyle(() => {
      return {
          transform: [{ rotate: `${interpolate(animationProgress.value, [0, 1], [0, 180])}deg` }],
      }
  })

  const defaultRenderItem: ListRenderItem<TItem> = ({ item }) => (
  	<TouchableOpacity style={styles.defaultItemContainer} onPress={() => handleItemPress(item)}>
  		<Text style={[styles.defaultItemText, dropdownItemTextStyle]}>{item.label}</Text>
  	</TouchableOpacity>
  );

  return (
  	<Animated.View
  		style={[
  			styles.outerContainer,
  			{ width: windowWidth - 32, backgroundColor: dropdownBackgroundColor }, // Background here for the whole area
  			containerStyle,
  			animatedOuterContainerStyle, // Height animation here
  		]}
  	>
  		<TouchableOpacity
  			activeOpacity={0.7}
  			onPress={toggleDropdown}
  			style={[styles.triggerArea, { height: INPUT_HEIGHT * fontScale}, triggerContainerStyle]}
  		>
  			<Text style={[styles.triggerText, triggerTextStyle]} numberOfLines={1}>
  				{selectedValue ? selectedValue.label : placeholder}
  			</Text>
  			<Animated.View style={animatedIconStyle}>
  				{triggerIcon}
  			</Animated.View>
  		</TouchableOpacity>

  		{/* The list part, only rendered when isOpen for performance, animated for visual effect */}
  		{isOpen && data.length > 0 && (
  			<Animated.View style={[styles.listWrapper, animatedDropdownContentStyle]}>
                  {/* you can replace FlatList with flashlist or legendlist just import and use */}
  				<FlatList
  					data={data}
  					renderItem={renderItem || defaultRenderItem}
  					keyExtractor={(item) => String(item.id)}
  					showsVerticalScrollIndicator={false}
  					keyboardShouldPersistTaps="handled"
  					ItemSeparatorComponent={
  						dropdownSeparatorColor
  							? () => <View style={[styles.separator, { backgroundColor: dropdownSeparatorColor }]} />
  							: () => <></>
  					}
  					style={{ maxHeight: listVisibleHeight }} // Ensure FlatList doesn't exceed calculated visible height
                      ListEmptyComponent={() => (
                          <View style={[styles.emptyListContainer, animatedDropdownContentStyle]}>
                              <Text style={styles.emptyListText}>No items available</Text>
                          </View>
                      )}
  				/>
  			</Animated.View>
  		)}
  	</Animated.View>
  );
};

const styles = StyleSheet.create({
  outerContainer: {
  	borderRadius: 8,
  	overflow: "hidden",
  	zIndex: 1,
  },
  triggerArea: {
  	flexDirection: "row",
  	alignItems: "center",
  	justifyContent: "space-between",
  	paddingHorizontal: 12,
  	zIndex: 2,
  },
  triggerText: {
  	flex: 1,
  	fontSize: 16,
  	color: "#333",
  	marginRight: 8,
  },
  listWrapper: {
  	flex: 1,
  	marginTop: INPUT_HEIGHT,
  	position: "absolute",
  	top: 0,
  	left: 0,
  	right: 0,
  	bottom: 0,
  },
  emptyListContainer: {
  	justifyContent: "center",
  	alignItems: "center",
  },
  emptyListText: {
  	color: "#888",
  	fontSize: 14,
  },
  defaultItemContainer: {
  	paddingVertical: 12,
  	paddingHorizontal: 15,
  },
  defaultItemText: {
  	fontSize: 16,
  	color: "#333",
  },
  separator: {
  	height: StyleSheet.hairlineWidth,
  	marginHorizontal: 16,
  },
});

export default DropdownPicker;

Usage

DropdownPickerPage.tsx
import React, { useState, useMemo } from "react";
import { View, Text, StyleSheet, SafeAreaView, ListRenderItem, TouchableOpacity, useWindowDimensions, Platform } from "react-native";
import DropdownPicker, { DropdownPickerItem } from "@/components/ui/DropdownPicker"; // Adjust path
import { useAppColors } from "@/hooks/useAppColors"; // Adjust path
import Ionicons from "@expo/vector-icons/Ionicons";

interface MyCustomItem extends DropdownPickerItem {
  description?: string;
}

const carMakesData: MyCustomItem[] = [
  { id: "1", label: "Toyota", value: "toyota", description: "Reliable Japanese brand" },
  { id: "2", label: "Honda", value: "honda", description: "Known for efficiency" },
  { id: "3", label: "Ford", value: "ford", description: "American classic" },
  { id: "4", label: "BMW", value: "bmw", description: "German luxury" },
  { id: "5", label: "Tesla", value: "tesla", description: "Electric innovation"},
];

const carModelsData: MyCustomItem[] = [
  { id: "m1", label: "Corolla", value: "corolla", description: "Toyota - Compact sedan" },
  { id: "m2", label: "Civic", value: "civic", description: "Honda - Popular compact" },
  { id: "m3", label: "F-150", value: "f150", description: "Ford - Best-selling truck" },
  { id: "m4", label: "Model 3", value: "model3", description: "Tesla - Electric sedan" },
  { id: "m5", label: "3 Series", value: "3series", description: "BMW - Sport sedan" },
  { id: "m6", label: "Accord", value: "accord", description: "Honda - Mid-size sedan" },
  { id: "m7", label: "Camry", value: "camry", description: "Toyota - Family sedan" },
];

export default function DropdownPickerPage() {
  const colors = useAppColors();
  const { width: windowWidth } = useWindowDimensions();

  const [selectedMake, setSelectedMake] = useState<MyCustomItem | null>(null);
  const [isMakePickerOpen, setIsMakePickerOpen] = useState(false);

  const [selectedModel, setSelectedModel] = useState<MyCustomItem | null>(null);
  const [isModelPickerOpen, setIsModelPickerOpen] = useState(false);

  const handleMakeSelect = (item: MyCustomItem) => {
      setSelectedMake(item);
      setIsMakePickerOpen(false);
      setSelectedModel(null); // Reset model if make changes
  };

  const handleModelSelect = (item: MyCustomItem) => {
      setSelectedModel(item);
      setIsModelPickerOpen(false);
  };

  // Toggling functions to ensure only one picker is open at a time
  const toggleMakePicker = (open: boolean) => {
      setIsMakePickerOpen(open);
      if (open && isModelPickerOpen) {
          setIsModelPickerOpen(false);
      }
  };

  const toggleModelPicker = (open: boolean) => {
      setIsModelPickerOpen(open);
      if (open && isMakePickerOpen) {
          setIsMakePickerOpen(false);
      }
  };
  
 // width calculation
  const pickerWidth = useMemo(() => {
      const totalPadding = styles.container.paddingHorizontal * 2;
      const gapBetweenPickers = styles.pickersRow.gap | 10;
      return (windowWidth - totalPadding - gapBetweenPickers) / 2;
  }, [windowWidth]);


  const renderCustomDropdownItem: ListRenderItem<MyCustomItem> = ({ item }) => (
      <TouchableOpacity
          style={[styles.customItem, { backgroundColor: colors.Neutral0 }]}
          onPress={() => {
              // Determine which picker this item belongs to or pass a specific handler
              if (carMakesData.find(d => d.id === item.id)) {
                  handleMakeSelect(item);
              } else {
                  handleModelSelect(item);
              }
          }}
      >
          {item.iconName && <Ionicons name={item.iconName} size={20} color={colors.PrimaryNormal} />}
          <View style={{ marginLeft: item.iconName ? 10 : 0, flex: 1 }}>
              <Text style={[styles.customItemLabel, { color: colors.Neutral900 }]}>{item.label}</Text>
              {item.description && (
                  <Text style={[styles.customItemDesc, { color: colors.Neutral500 }]} numberOfLines={1}>
                      {item.description}
                  </Text>
              )}
          </View>
      </TouchableOpacity>
  );

  return (
      <SafeAreaView style={[styles.safeArea, { backgroundColor: colors.bgColor }]}>
          <View style={styles.container}>
              <Text style={[styles.title, { color: colors.Neutral900 }]}>Select Vehicle</Text>

              <View style={styles.pickersRow}>
                  <DropdownPicker<MyCustomItem>
                      data={carMakesData}
                      isOpen={isMakePickerOpen}
                      setIsOpen={toggleMakePicker}
                      onItemSelected={handleMakeSelect}
                      selectedValue={selectedMake}
                      placeholder="Select Make"
                      renderItem={renderCustomDropdownItem}
                      triggerIcon={<Ionicons name="chevron-down-outline" size={22} color={colors.Neutral500} />}
                      dropdownBackgroundColor={colors.Neutral0}
                      dropdownItemTextStyle={{ color: colors.Neutral700 }}
                      triggerTextStyle={{ color: colors.Neutral700 }}
                      containerStyle={{ 
                          width: pickerWidth, 
                      }} 
                      itemHeight={Platform.OS === 'ios' ? 55 : 60}
  					reduceMotion="never"
                  />
                  <DropdownPicker<MyCustomItem>
                      data={carModelsData.filter(model => selectedMake ? model.description?.startsWith(selectedMake.label) : true)} // Example: Filter models by make
                      isOpen={isModelPickerOpen}
                      setIsOpen={toggleModelPicker} 
                      onItemSelected={handleModelSelect}
                      selectedValue={selectedModel}
                      placeholder="Select Model"
                      renderItem={renderCustomDropdownItem}
                      triggerIcon={<Ionicons name="chevron-down-outline" size={22} color={colors.Neutral500} />}
                      dropdownBackgroundColor={colors.Neutral0}
                      dropdownItemTextStyle={{ color: colors.Neutral700 }}
                      triggerTextStyle={{ color: colors.Neutral700 }}
  					triggerContainerStyle={{ borderColor: colors.PrimaryNormal, backgroundColor: colors.Neutral0 }}
  					reduceMotion="never"
  					
  					dropdownSeparatorColor={colors.Neutral500}
  					containerStyle={{ 
                          width: pickerWidth, 
                      }}
                      itemHeight={Platform.OS === 'ios' ? 55 : 60} // Adjust if custom items are talle
                  />
              </View>
          </View>
      </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  safeArea: { flex: 1 },
  container: {
      flex: 1,
      paddingHorizontal: 16,
      paddingTop: 30,
  },
  title: {
      fontSize: 24,
      fontWeight: "bold",
      marginBottom: 25,
      textAlign: "center",
  },
  pickersRow: {
      flexDirection: "row",
      justifyContent: "space-between", 
      alignItems: "flex-start", 
      width: "100%",
      marginBottom: 30,
  	gap: 10,
  },
  customItem: {
      flexDirection: "row",
      alignItems: "center",
      paddingVertical: 12,
      paddingHorizontal: 16,
  },
  customItemLabel: {
      fontSize: 16,
      fontWeight: "500",
  },
  customItemDesc: {
      fontSize: 13,
      marginTop: 2,
  },
});

Props

PropTypeDefaultRequiredDescription
isOpenbooleanYesControls whether the dropdown list is currently open or closed.
setIsOpen(open: boolean) => void;YesFunction to update the isOpen state, typically from a parent component.
dataTItem[] (where TItem extends DropdownPickerItem)YesAn array of item objects to display in the dropdown. Each item must have id, label, and value.
onItemSelected(item: TItem) => voidYesCallback function triggered when an item is selected from the dropdown list.
inputHeightnumber60 (INPUT_HEIGHT)NoThe height of the touchable trigger area of the dropdown. (Note: INPUT_HEIGHT is used in the code, might need to be exposed or consistent)
renderItemListRenderItem<TItem>NoOptional custom function to render each item in the dropdown list. If not provided, a default renderer is used.
placeholderstring"Select an item..."NoPlaceholder text displayed in the trigger area when no item is selected.
selectedValueTItem | nullNoThe currently selected item object. If provided, its label will be displayed in the trigger area.
triggerContainerStyleStyleProp<ViewStyle>NoCustom styles applied to the touchable trigger area TouchableOpacity.
triggerTextStyleStyleProp<TextStyle>NoCustom styles applied to the text (Text) within the trigger area.
triggerIconReact.ReactElementNoOptional React Element (e.g., an Icon component) to display as the dropdown arrow/indicator in the trigger area.
dropdownBackgroundColorstring"#FFFFFF"NoBackground color for the entire dropdown component, including the list area.
dropdownMaxHeightnumberwindowHeight * 0.4NoMaximum height for the scrollable list of items. Defaults to 40% of the window height.
dropdownItemTextStyleStyleProp<TextStyle>NoCustom styles applied to the text of each item in the dropdown list (used by the default renderItem).
dropdownSeparatorColorstring"#E0E0E0"NoColor for the separator line between items in the dropdown list. If not provided, no separator is rendered.
animationDurationnumber300NoDuration of the open/close animation in milliseconds.
reduceMotion"never" | "always" | "system""system"NoControls animation behavior: 'never', 'always' (no animation), or 'system' (respects device settings).
containerStyleStyleProp<ViewStyle>NoCustom styles applied to the outermost animated View container of the dropdown.
itemHeightnumber45NoEstimated height of a single item in the list, used to calculate the dropdown's animated height.