Bottom Sheet
A customizable bottom sheet with smooth snap points, drag gestures, and scroll-aware behavior, supporting dynamic heights, backdrop dismissal, and nested scrolling.
Last updated on
Manual
Install the following dependencies:
npm install react-native-reanimated react-native-gesture-handler react-native-workletsCopy and paste the following code into your project.
component/templates/bottom-sheet
// @ts-checkimport React, { memo, useCallback, useMemo, forwardRef, useImperativeHandle, useState, ReactElement, cloneElement, Children,} from "react";import { GestureDetector, Gesture, type PanGesture,} from "react-native-gesture-handler";import Animated, { useSharedValue, useAnimatedStyle, withSpring, withTiming, interpolate, Extrapolation, useAnimatedRef, scrollTo, useAnimatedScrollHandler,} from "react-native-reanimated";import { StyleSheet, Dimensions, View, Pressable, ScrollViewProps, ViewStyle,} from "react-native";import { scheduleOnRN } from "react-native-worklets";// @ts-ignoreimport type { BottomSheetProps, BottomSheetMethods } from "./types";import { DEFAULT_SPRING_CONFIG, DEFAULT_TIMING_CONFIG, HANDLE_HEIGHT, SCROLL_TOP_THRESHOLD,} from "./conf";import { parseSnapPoint, triggerHaptic, isScrollableList } from "./utils";const { height: SCREEN_HEIGHT } = Dimensions.get("window");const BottomSheetComponent = forwardRef<BottomSheetMethods, BottomSheetProps>( ( { children, snapPoints, enableBackdrop = true, backdropOpacity = 0.5, dismissOnBackdropPress = true, dismissOnSwipeDown = true, onSnapPointChange, onClose, springConfig = DEFAULT_SPRING_CONFIG, sheetStyle, backdropStyle, handleStyle, showHandle = true, enableOverDrag = true, enableHapticFeedback = true, snapVelocityThreshold = 500, backgroundColor = "#FFFFFF", borderRadius = 24, contentContainerStyle, enableDynamicSizing = false, }, ref, ) => { const parsedSnapPoints = useMemo<number[]>( () => snapPoints.map(parseSnapPoint), [snapPoints], ); const maxSnapPoint = useMemo<number>( () => Math.max(...parsedSnapPoints), [parsedSnapPoints], ); const minSnapPoint = useMemo<number>( () => Math.min(...parsedSnapPoints), [parsedSnapPoints], ); const maxSnapIndex = useMemo<number>( () => parsedSnapPoints.length - 1, [parsedSnapPoints], ); const translateY = useSharedValue<number>(SCREEN_HEIGHT); const currentSnapIndex = useSharedValue<number>(-1); const context = useSharedValue<number>(0); const scrollY = useSharedValue<number>(0); const scrollViewRef = useAnimatedRef<Animated.ScrollView>(); const isDraggingSheet = useSharedValue<boolean>(false); const isScrollLocked = useSharedValue<boolean>(false); const gestureStartScrollY = useSharedValue<number>(0); const [enableScroll, setEnableScroll] = useState<boolean>(false); const handleSnapPointChangeJS = useCallback( (index: number) => { if (enableHapticFeedback) { triggerHaptic(); } onSnapPointChange?.(index); }, [onSnapPointChange, enableHapticFeedback], ); const handleCloseJS = useCallback(() => { if (enableHapticFeedback) { triggerHaptic(); } onClose?.(); }, [onClose, enableHapticFeedback]); const updateScrollEnabled = useCallback((enabled: boolean) => { setEnableScroll(enabled); }, []); const findClosestSnapPoint = useCallback( (currentY: number, velocity: number): number => { "worklet"; const height = SCREEN_HEIGHT - currentY; if (Math.abs(velocity) > snapVelocityThreshold) { const direction = velocity > 0 ? -1 : 1; const currentIndex = currentSnapIndex.value; const nextIndex = currentIndex + direction; if (nextIndex >= 0 && nextIndex < parsedSnapPoints.length) { return nextIndex; } } let closestIndex = 0; let minDistance = Math.abs(height - parsedSnapPoints[0]); for (let i = 1; i < parsedSnapPoints.length; i++) { const distance = Math.abs(height - parsedSnapPoints[i]); if (distance < minDistance) { minDistance = distance; closestIndex = i; } } return closestIndex; }, [parsedSnapPoints, snapVelocityThreshold], ); const snapToPoint = useCallback( (index: number, animated: boolean = true) => { "worklet"; if (index < 0 || index >= parsedSnapPoints.length) { return; } const targetY = SCREEN_HEIGHT - parsedSnapPoints[index]; if (animated) { translateY.value = withSpring(targetY, springConfig); } else { translateY.value = targetY; } currentSnapIndex.value = index; const shouldEnableScroll = index === maxSnapIndex; isScrollLocked.value = !shouldEnableScroll; scheduleOnRN<[boolean], void>(updateScrollEnabled, shouldEnableScroll); if (onSnapPointChange) { scheduleOnRN<[number], void>(handleSnapPointChangeJS, index); } }, [ parsedSnapPoints, springConfig, translateY, currentSnapIndex, maxSnapIndex, isScrollLocked, handleSnapPointChangeJS, updateScrollEnabled, ], ); const closeSheet = useCallback(() => { "worklet"; isScrollLocked.value = true; scheduleOnRN<[boolean], void>(updateScrollEnabled, false); translateY.value = withTiming<number>( SCREEN_HEIGHT, DEFAULT_TIMING_CONFIG, (finished) => { if (finished) { currentSnapIndex.value = -1; scrollTo<Animated.ScrollView>(scrollViewRef, 0, 0, false); scrollY.value = 0; if (onClose) { scheduleOnRN<[], void>(handleCloseJS); } } }, ); }, [ translateY, handleCloseJS, scrollViewRef, scrollY, isScrollLocked, updateScrollEnabled, ]); const onScroll = useAnimatedScrollHandler({ onScroll: (event) => { "worklet"; scrollY.value = event.contentOffset.y; }, }); const handlePanGesture = useMemo<PanGesture>( () => Gesture.Pan() .onBegin(() => { "worklet"; context.value = translateY.value; isDraggingSheet.value = true; }) .onUpdate((event) => { "worklet"; const newY = context.value + event.translationY; const minY = SCREEN_HEIGHT - maxSnapPoint; const maxY = SCREEN_HEIGHT; if (enableOverDrag) { if (newY < minY) { const overDrag = minY - newY; translateY.value = minY - Math.log(overDrag + 1) * 10; } else if (newY > maxY) { const overDrag = newY - maxY; translateY.value = maxY + Math.log(overDrag + 1) * 10; } else { translateY.value = newY; } } else { translateY.value = Math.max(minY, Math.min(maxY, newY)); } }) .onEnd((event) => { "worklet"; isDraggingSheet.value = false; const currentY = translateY.value; const velocity = event.velocityY; if ( dismissOnSwipeDown && currentY > SCREEN_HEIGHT - minSnapPoint && velocity > 500 ) { closeSheet(); return; } const closestIndex = findClosestSnapPoint(currentY, velocity); snapToPoint(closestIndex, true); }), [ translateY, context, isDraggingSheet, enableOverDrag, maxSnapPoint, minSnapPoint, dismissOnSwipeDown, closeSheet, findClosestSnapPoint, snapToPoint, ], ); const contentPanGesture = useMemo( () => Gesture.Pan() .activeOffsetY([-10, 10]) .onStart(() => { "worklet"; context.value = translateY.value; gestureStartScrollY.value = scrollY.value; isDraggingSheet.value = false; }) .onUpdate((event) => { "worklet"; const isFullyExpanded = currentSnapIndex.value === maxSnapIndex; if (!isFullyExpanded) { isDraggingSheet.value = true; const newY = context.value + event.translationY; const minY = SCREEN_HEIGHT - maxSnapPoint; const maxY = SCREEN_HEIGHT; if (newY < minY) { translateY.value = enableOverDrag ? minY - Math.log(minY - newY + 1) * 10 : minY; } else if (newY > maxY) { translateY.value = enableOverDrag ? maxY + Math.log(newY - maxY + 1) * 10 : maxY; } else { translateY.value = newY; } return; } const isAtTop = scrollY.value <= SCROLL_TOP_THRESHOLD; const isDraggingDown = event.translationY > 0; const wasAtTopAtStart = gestureStartScrollY.value <= SCROLL_TOP_THRESHOLD; const shouldDragSheet = isDraggingSheet.value || (isAtTop && isDraggingDown && wasAtTopAtStart); if (!shouldDragSheet) { return; } isDraggingSheet.value = true; const effectiveTranslation = event.translationY; const newY = context.value + effectiveTranslation; const minY = SCREEN_HEIGHT - maxSnapPoint; const maxY = SCREEN_HEIGHT; if (newY < minY) { translateY.value = enableOverDrag ? minY - Math.log(minY - newY + 1) * 10 : minY; } else if (newY > maxY) { translateY.value = enableOverDrag ? maxY + Math.log(newY - maxY + 1) * 10 : maxY; } else { translateY.value = newY; } }) .onEnd((event) => { "worklet"; if (isDraggingSheet.value) { const currentY = translateY.value; const velocity = event.velocityY; if ( dismissOnSwipeDown && currentY > SCREEN_HEIGHT - minSnapPoint && velocity > 500 ) { closeSheet(); } else { const closestIndex = findClosestSnapPoint(currentY, velocity); snapToPoint(closestIndex, true); } } isDraggingSheet.value = false; }) .onFinalize(() => { "worklet"; isDraggingSheet.value = false; }), [ translateY, context, scrollY, gestureStartScrollY, isDraggingSheet, currentSnapIndex, maxSnapIndex, enableOverDrag, maxSnapPoint, minSnapPoint, dismissOnSwipeDown, closeSheet, findClosestSnapPoint, snapToPoint, ], ); const scrollViewGesture = useMemo(() => Gesture.Native(), []); const simultaneousGesture = useMemo( () => Gesture.Simultaneous(scrollViewGesture, contentPanGesture), [scrollViewGesture, contentPanGesture], ); useImperativeHandle( ref, () => ({ snapToIndex: (index: number) => { snapToPoint(index, true); }, snapToPosition: (position: number) => { "worklet"; const targetY = SCREEN_HEIGHT - position; translateY.value = withSpring(targetY, springConfig); }, expand: () => { snapToPoint(maxSnapIndex, true); }, collapse: () => { snapToPoint(0, true); }, close: () => { closeSheet(); }, getCurrentIndex: () => { return currentSnapIndex.value; }, }), [ snapToPoint, closeSheet, maxSnapIndex, springConfig, translateY, currentSnapIndex, ], ); const sheetAnimatedStyle = useAnimatedStyle<Pick<ViewStyle, "transform">>( () => ({ transform: [{ translateY: translateY.value }], }), ); const contentAnimatedStyle = useAnimatedStyle<Pick<ViewStyle, "height">>( () => { const visibleHeight = SCREEN_HEIGHT - translateY.value; const contentHeight = Math.max( 0, visibleHeight - (showHandle ? HANDLE_HEIGHT : 0), ); return { height: contentHeight, }; }, ); const backdropAnimatedStyle = useAnimatedStyle< Pick<ViewStyle, "opacity" | "pointerEvents"> >(() => { const opacity = interpolate( translateY.value, [SCREEN_HEIGHT - maxSnapPoint, SCREEN_HEIGHT], [backdropOpacity, 0], Extrapolation.CLAMP, ); return { opacity, pointerEvents: opacity > 0 ? ("auto" as const) : ("none" as const), }; }); const handleBackdropPress = useCallback(() => { if (dismissOnBackdropPress) { closeSheet(); } }, [dismissOnBackdropPress, closeSheet]); const sheetBaseStyle = useMemo< Pick< ViewStyle, "backgroundColor" | "borderTopLeftRadius" | "borderTopRightRadius" > >( () => ({ backgroundColor, borderTopLeftRadius: borderRadius, borderTopRightRadius: borderRadius, }), [backgroundColor, borderRadius], ); const scrollProps: Partial<ScrollViewProps> = useMemo( () => ({ scrollEnabled: enableScroll, onScroll: onScroll as ScrollViewProps["onScroll"], scrollEventThrottle: 16, bounces: false, overScrollMode: "never" as const, showsVerticalScrollIndicator: true, nestedScrollEnabled: true, }), [enableScroll, onScroll], ); const renderContent = useCallback(() => { const childArray = Children.toArray(children); if (childArray.length === 1 && isScrollableList(childArray[0])) { const listElement = childArray[0] as ReactElement; const enhancedList = cloneElement(listElement, { ...scrollProps, onScroll: (event: any) => { (scrollProps.onScroll as any)?.(event); (listElement.props as any).onScroll?.(event); }, }); return ( <GestureDetector gesture={simultaneousGesture}> <Animated.View style={styles.scrollableWrapper}> {enhancedList} </Animated.View> </GestureDetector> ); } const hasScrollableChild = childArray.some(isScrollableList); if (hasScrollableChild) { const enhancedChildren = childArray.map((child, index) => { if (isScrollableList(child)) { const listElement = child as ReactElement; return cloneElement(listElement, { key: (listElement.key as string) || index, ...scrollProps, onScroll: (event: any) => { (scrollProps.onScroll as any)?.(event); (listElement.props as any).onScroll?.(event); }, }); } return child; }); return ( <GestureDetector gesture={simultaneousGesture}> <Animated.View style={styles.scrollableWrapper}> {enhancedChildren} </Animated.View> </GestureDetector> ); } return ( <GestureDetector gesture={simultaneousGesture}> <Animated.ScrollView ref={scrollViewRef} style={styles.scrollView} contentContainerStyle={contentContainerStyle} scrollEnabled={enableScroll} onScroll={onScroll} scrollEventThrottle={16} showsVerticalScrollIndicator={true} bounces={false} nestedScrollEnabled={true} keyboardShouldPersistTaps="handled" overScrollMode="never" > {children} </Animated.ScrollView> </GestureDetector> ); }, [ children, scrollProps, simultaneousGesture, scrollViewRef, contentContainerStyle, enableScroll, onScroll, ]); return ( <View style={styles.container} pointerEvents="box-none"> {enableBackdrop && ( <Animated.View style={[styles.backdrop, backdropAnimatedStyle, backdropStyle]} > <Pressable style={StyleSheet.absoluteFillObject} onPress={handleBackdropPress} /> </Animated.View> )} <Animated.View style={[styles.sheet, sheetBaseStyle, sheetAnimatedStyle, sheetStyle]} > {showHandle && ( <GestureDetector gesture={handlePanGesture}> <View style={styles.handleContainer}> <View style={[styles.handle, handleStyle]} /> </View> </GestureDetector> )} <Animated.View style={[styles.contentWrapper, contentAnimatedStyle]}> {renderContent()} </Animated.View> </Animated.View> </View> ); },);export const BottomSheet = memo< React.ForwardRefExoticComponent< BottomSheetProps & React.RefAttributes<BottomSheetMethods> > >(BottomSheetComponent);const styles = StyleSheet.create({ container: { ...StyleSheet.absoluteFillObject, }, backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: "#000000", }, sheet: { position: "absolute", left: 0, right: 0, top: 0, height: SCREEN_HEIGHT, shadowColor: "#000", shadowOffset: { width: 0, height: -2, }, shadowOpacity: 0.25, shadowRadius: 8, elevation: 5, }, handleContainer: { alignItems: "center", paddingVertical: 12, }, handle: { width: 40, height: 4, borderRadius: 2, backgroundColor: "#D1D5DB", }, contentWrapper: { overflow: "hidden", }, scrollView: { flex: 1, }, scrollableWrapper: { flex: 1, },});export default BottomSheet;Usage
import { View, StyleSheet, Text, Pressable } from "react-native";import { GestureHandlerRootView } from "react-native-gesture-handler";import { StatusBar } from "expo-status-bar";import { useRef } from "react";import { useFonts } from "expo-font";import { Feather } from "@expo/vector-icons";import { BottomSheetMethods } from "@/components/templates/bottom-sheet/types";import BottomSheet from "@/components/templates/bottom-sheet";export default function App() { const sheetRef = useRef<BottomSheetMethods>(null); const [fontLoaded] = useFonts({ SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"), HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"), }); const ListItem = ({ icon, label, isLast = false, }: { icon: string; label: string; isLast?: boolean; }) => ( <Pressable style={[styles.listItem, isLast && styles.listItemLast]}> <Feather name={icon as any} size={18} color="#888" /> <Text style={[styles.listText, fontLoaded && { fontFamily: "SfProRounded" }]} > {label} </Text> <Feather name="chevron-right" size={16} color="#444" /> </Pressable> ); return ( <GestureHandlerRootView style={styles.container}> <StatusBar style="light" /> <Pressable style={styles.trigger} onPress={() => sheetRef.current?.snapToIndex(0)} > <Text style={[ styles.triggerText, fontLoaded && { fontFamily: "SfProRounded" }, ]} > Edit Profile </Text> </Pressable> <BottomSheet ref={sheetRef} snapPoints={["50%", "90%"]} backgroundColor="#1c1c1e" backdropOpacity={0.6} borderRadius={28} > <View style={styles.sheet}> {/* Header */} <View style={styles.header}> <View style={styles.avatar}> <Feather name="user" size={32} color="#fff" /> </View> <Text style={[ styles.name, fontLoaded && { fontFamily: "HelveticaNowDisplay" }, ]} > John Doe </Text> <Text style={[ styles.email, fontLoaded && { fontFamily: "SfProRounded" }, ]} > john@example.com </Text> </View> {/* Action Row */} <View style={styles.row}> <Pressable style={styles.rowItem}> <Feather name="edit-2" size={18} color="#0a84ff" /> <Text style={[ styles.rowText, fontLoaded && { fontFamily: "SfProRounded" }, ]} > Edit </Text> </Pressable> <View style={styles.rowDivider} /> <Pressable style={styles.rowItem}> <Feather name="log-out" size={18} color="#ff453a" /> <Text style={[ styles.rowText, { color: "#ff453a" }, fontLoaded && { fontFamily: "SfProRounded" }, ]} > Logout </Text> </Pressable> </View> {/* General Section */} <Text style={[ styles.sectionTitle, fontLoaded && { fontFamily: "SfProRounded" }, ]} > General </Text> <View style={styles.list}> <ListItem icon="bell" label="Notifications" /> <ListItem icon="moon" label="Appearance" /> <ListItem icon="globe" label="Language" isLast /> </View> {/* Privacy Section */} <Text style={[ styles.sectionTitle, fontLoaded && { fontFamily: "SfProRounded" }, ]} > Privacy </Text> <View style={styles.list}> <ListItem icon="lock" label="Security" /> <ListItem icon="shield" label="Data" isLast /> </View> </View> </BottomSheet> </GestureHandlerRootView> );}const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#000", justifyContent: "center", alignItems: "center", }, trigger: { backgroundColor: "#fff", paddingVertical: 14, paddingHorizontal: 28, borderRadius: 14, }, triggerText: { fontSize: 16, fontWeight: "600", color: "#000", }, sheet: { paddingHorizontal: 20, paddingTop: 16, }, header: { alignItems: "center", marginBottom: 20, }, avatar: { width: 72, height: 72, borderRadius: 36, backgroundColor: "#2c2c2e", justifyContent: "center", alignItems: "center", marginBottom: 12, }, name: { fontSize: 20, color: "#fff", marginBottom: 4, }, email: { fontSize: 14, color: "#666", }, row: { flexDirection: "row", backgroundColor: "#2c2c2e", borderRadius: 14, marginBottom: 24, }, rowItem: { flex: 1, flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 8, paddingVertical: 14, }, rowDivider: { width: 1, backgroundColor: "#3a3a3c", }, rowText: { fontSize: 15, color: "#0a84ff", fontWeight: "500", }, sectionTitle: { fontSize: 13, color: "#666", textTransform: "uppercase", letterSpacing: 0.5, marginBottom: 8, marginLeft: 4, }, list: { backgroundColor: "#2c2c2e", borderRadius: 14, marginBottom: 20, }, listItem: { flexDirection: "row", alignItems: "center", paddingVertical: 13, paddingHorizontal: 14, borderBottomWidth: 1, borderBottomColor: "#3a3a3c", }, listItemLast: { borderBottomWidth: 0, }, listText: { flex: 1, fontSize: 15, color: "#fff", marginLeft: 12, },});Props
BottomSheetMethods
React Native Reanimated
React Native Worklets
React Native Gesture Handler
