Dropdown
Animated dropdown menu that opens near it's trigger with auto-position
Last updated on
Manual
Install the following dependencies:
npm install react-native-reanimated react-native-gesture-handler react-native-worklets expo-hapticsCopy and paste the following code into your project.
component/organisms/dropdown
import React, { useState, createContext, useContext, useRef, ReactNode, Children, useCallback,} from "react";import { View, StyleSheet, Modal, TouchableOpacity } from "react-native";import Animated, { useSharedValue, useAnimatedStyle, withTiming, interpolate, Easing, withSpring,} from "react-native-reanimated";import { Gesture, GestureDetector } from "react-native-gesture-handler";import * as Haptics from "expo-haptics";import { SCREEN_WIDTH, SCREEN_HEIGHT, SPACING } from "./const";import type { ContentProps, DropdownContextValue, ItemProps, Styles, TriggerLayout, TriggerProps,} from "./types";import { scheduleOnRN } from "react-native-worklets";const DropdownContext = createContext<DropdownContextValue | undefined>( undefined,);const useDropdownContext = (): DropdownContextValue => { const context = useContext(DropdownContext); if (!context) throw new Error("Dropdown components must be used within a Dropdown"); return context;};interface DropdownProps { children: ReactNode;}const Dropdown = ({ children }: DropdownProps): JSX.Element => { const [visible, setVisible] = useState<boolean>(false); const [triggerLayout, setTriggerLayout] = useState<TriggerLayout | null>( null, ); const flipAnim = useSharedValue<number>(0); const activeItemIndex = useSharedValue<number>(-1); const open = (): void => { setVisible(true); flipAnim.value = withSpring(1, { damping: 15, stiffness: 150, mass: 0.8, }); }; const close = (): void => { flipAnim.value = withTiming(0, { duration: 200, easing: Easing.bezier(0.4, 0, 0.6, 1), }); activeItemIndex.value = -1; setTimeout(() => setVisible(false), 200); }; return ( <DropdownContext.Provider value={{ visible, open, close, triggerLayout, setTriggerLayout, flipAnim, activeItemIndex, }} > {children} </DropdownContext.Provider> );};const Trigger = ({ children, style }: TriggerProps): JSX.Element => { const { open, setTriggerLayout } = useDropdownContext(); const triggerRef = useRef<View>(null); const handlePress = (): void => { triggerRef.current?.measure( ( _x: number, _y: number, width: number, height: number, pageX: number, pageY: number, ) => { setTriggerLayout({ x: pageX, y: pageY, width, height }); open(); }, ); }; return ( <TouchableOpacity ref={triggerRef} onPress={handlePress} style={style} activeOpacity={0.7} > {children} </TouchableOpacity> );};const Content = ({ children, style, position = "auto",}: ContentProps): JSX.Element | null => { const { visible, close, triggerLayout, flipAnim, activeItemIndex } = useDropdownContext(); const itemCount = Children.count(children); const lastHapticIndex = useSharedValue<number>(-1); const contentRef = useRef<View>(null); const [contentDimensions, setContentDimensions] = useState<{ width: number; height: number; } | null>(null); const triggerHaptic = useCallback( () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light), [], ); const calculatePosition = useCallback(() => { if (!triggerLayout || !contentDimensions) return { top: 0, left: 0 }; const { x, y, width, height } = triggerLayout; const { width: contentWidth, height: contentHeight } = contentDimensions; let top = y + height + SPACING; let left = x; // Auto positioning logic if (position === "auto") { // Check bottom space const spaceBelow = SCREEN_HEIGHT - (y + height); const spaceAbove = y; const spaceRight = SCREEN_WIDTH - x; const spaceLeft = x; // Vertical positioning if (spaceBelow >= contentHeight + SPACING) { top = y + height + SPACING; // Bottom } else if (spaceAbove >= contentHeight + SPACING) { top = y - contentHeight - SPACING; // Top } else { // Not enough space on either side, position with maximum visibility top = spaceBelow > spaceAbove ? y + height + SPACING : Math.max(SPACING, y - contentHeight - SPACING); } // Horizontal positioning if (x + contentWidth > SCREEN_WIDTH - SPACING) { left = Math.max(SPACING, x + width - contentWidth); } if (left < SPACING) left = SPACING; // Ensure it doesn't go off right edge if (left + contentWidth > SCREEN_WIDTH - SPACING) { left = SCREEN_WIDTH - contentWidth - SPACING; } } else if (position === "top") { top = y - contentHeight - SPACING; } else if (position === "bottom") { top = y + height + SPACING; } else if (position === "left") { left = x - contentWidth - SPACING; top = y; } else if (position === "right") { left = x + width + SPACING; top = y; } // Final bounds check top = Math.max( SPACING, Math.min(top, SCREEN_HEIGHT - contentHeight - SPACING), ); left = Math.max( SPACING, Math.min(left, SCREEN_WIDTH - contentWidth - SPACING), ); return { top, left }; }, [triggerLayout, contentDimensions, position]); const { top, left } = calculatePosition(); const calculateActiveIndex = useCallback( (y: number) => { "worklet"; const ITEM_HEIGHT = 44; const CONTAINER_PADDING = 8; const relativeY = y - CONTAINER_PADDING; const index = Math.floor(relativeY / ITEM_HEIGHT); if (index < 0) return 0; if (index >= itemCount) return itemCount - 1; return index; }, [itemCount], ); const panGesture = Gesture.Pan() .minDistance(0) .onBegin((event) => { "worklet"; const index = calculateActiveIndex(event.y); activeItemIndex.value = index; lastHapticIndex.value = index; scheduleOnRN(triggerHaptic); }) .onUpdate((event) => { "worklet"; const index = calculateActiveIndex(event.y); if (index !== activeItemIndex.value) { activeItemIndex.value = index; if (index !== lastHapticIndex.value) { lastHapticIndex.value = index; scheduleOnRN(triggerHaptic); } } }) .onEnd(() => { "worklet"; activeItemIndex.value = -1; lastHapticIndex.value = -1; }) .onFinalize(() => { "worklet"; activeItemIndex.value = -1; lastHapticIndex.value = -1; }); const animatedStyle = useAnimatedStyle(() => { const progress = flipAnim.value; return { opacity: interpolate(progress, [0, 0.5, 1], [0, 0.5, 1]), transform: [ { perspective: 900 }, { scale: interpolate(progress, [0, 1], [0.9, 1]), }, ], transformOrigin: "top center", }; }); if (!visible || !triggerLayout) return null; const childrenWithIndex = Children.map(children, (child, index) => React.isValidElement(child) ? React.cloneElement(child, { ...child.props, index } as any) : child, ); return ( <Modal transparent visible={visible} onRequestClose={close} animationType="none" > <TouchableOpacity style={styles.overlay} activeOpacity={1} onPress={close} > <GestureDetector gesture={panGesture}> <Animated.View ref={contentRef} onLayout={(e) => { const { width, height } = e.nativeEvent.layout; setContentDimensions({ width, height }); }} style={[ styles.content, style, { top: contentDimensions ? top : triggerLayout.y + triggerLayout.height + SPACING, left: contentDimensions ? left : triggerLayout.x, minWidth: triggerLayout.width, }, animatedStyle, ]} > {childrenWithIndex} </Animated.View> </GestureDetector> </TouchableOpacity> </Modal> );};const Item = ({ children, onPress, style, index = 0,}: ItemProps): JSX.Element => { const { close, activeItemIndex } = useDropdownContext(); const handlePress = (): void => { onPress?.(); close(); }; const animatedStyle = useAnimatedStyle(() => { const isActive = activeItemIndex.value === index; const LIFT_DISTANCE = -6; const SCALE_UP = 1.02; return { transform: [ // { // translateY: withTiming(isActive ? LIFT_DISTANCE : 0, { // duration: 120, // easing: Easing.bezier(0.25, 0.1, 0.25, 1), // }), // }, { scale: withTiming(isActive ? SCALE_UP : 1, { duration: 120, easing: Easing.bezier(0.25, 0.1, 0.25, 1), }), }, ], zIndex: isActive ? 100 : 1, backgroundColor: withTiming(isActive ? "#f3f4f6" : "transparent", { duration: 120, }), }; }); const tap = Gesture.Tap().onEnd(() => scheduleOnRN(handlePress)); return ( <GestureDetector gesture={tap}> <Animated.View style={[styles.item, style, animatedStyle]}> {children} </Animated.View> </GestureDetector> );};Dropdown.Trigger = Trigger;Dropdown.Content = Content;Dropdown.Item = Item;const styles = StyleSheet.create<Styles>({ overlay: { flex: 1 }, content: { position: "absolute", borderRadius: 12, padding: 8, shadowColor: "#000", shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.15, shadowRadius: 12, elevation: 8, }, item: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingVertical: 12, paddingHorizontal: 16, borderRadius: 8, },});export default Dropdown;Usage
import { View, Text, StyleSheet } from "react-native";import { GestureHandlerRootView } from "react-native-gesture-handler";import { StatusBar } from "expo-status-bar";import { Ionicons } from "@expo/vector-icons";import Dropdown from "@/components/organisms/dropdown";import { useFonts } from "expo-font";export default function App(): React.JSX.Element { const [fontLoaded] = useFonts({ SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"), HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"), Coolvetica: require("@/assets/fonts/Coolvetica-Rg.otf"), }); return ( <GestureHandlerRootView style={styles.container}> <StatusBar style="light" /> <View style={styles.card}> <View style={styles.header}> <View> <Text style={[ styles.title, { fontFamily: fontLoaded ? "SfProRounded" : undefined }, ]} > Reacticx </Text> <Text style={[ styles.subtitle, { fontFamily: fontLoaded ? "HelveticaNowDisplay" : undefined, }, ]} > Last updated 2 mins ago </Text> </View> <Dropdown> <Dropdown.Trigger style={styles.trigger}> <Ionicons name="ellipsis-horizontal" size={20} color="#fff" /> </Dropdown.Trigger> <Dropdown.Content style={styles.menu}> <Dropdown.Item onPress={() => console.log("Edit")}> <Text style={styles.itemText}>Edit</Text> <Ionicons name="pencil" size={16} color="#111" /> </Dropdown.Item> <Dropdown.Item onPress={() => console.log("Copy")} style={{ gap: 8, }} > <Text style={styles.itemText}>Copy</Text> <Ionicons name="copy-outline" size={16} color="#111" /> </Dropdown.Item> <Dropdown.Item onPress={() => console.log("Archive")} style={{ gap: 8, }} > <Text style={styles.itemText}>Archive</Text> <Ionicons name="archive-outline" size={16} color="#111" /> </Dropdown.Item> <Dropdown.Item onPress={() => console.log("Delete")}> <Text style={[styles.itemText, styles.destructive]}> Delete </Text> <Ionicons name="trash-outline" size={16} color="#dc2626" /> </Dropdown.Item> </Dropdown.Content> </Dropdown> </View> <Text style={[ styles.body, { fontFamily: fontLoaded ? "Coolvetica" : undefined, }, ]} > I love all the people who use Reacticx to build amazing apps, and I am grateful for the opportunity to create this tool for you. </Text> </View> </GestureHandlerRootView> );}const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#000", paddingHorizontal: 24, }, card: { backgroundColor: "rgba(255,255,255,0.08)", borderRadius: 20, padding: 20, top: 120, }, header: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", }, title: { color: "#fff", fontSize: 17, fontWeight: "600", }, subtitle: { color: "rgba(255,255,255,0.6)", fontSize: 13, marginTop: 2, }, body: { marginTop: 12, color: "rgba(255,255,255,0.75)", fontSize: 14, lineHeight: 20, }, trigger: { width: 36, height: 36, borderRadius: 10, backgroundColor: "rgba(255,255,255,0.12)", justifyContent: "center", alignItems: "center", }, menu: { backgroundColor: "#fff", }, itemText: { fontSize: 15, color: "#111", }, destructive: { color: "#dc2626", },});Props
ItemProps
React Native Reanimated
React Native Worklets
React Native Gesture Handler
Expo Haptics
