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

Edit on GitHub

Manual

Install the following dependencies:

npm install react-native-reanimated react-native-gesture-handler react-native-worklets

Copy 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