React Native 101: The only bottom sheet guide you'll ever need

React Native 101: The only bottom sheet guide you'll ever need

A step-by-step walkthrough of how to create your own bottom sheet component that's fully customizable and reusable.

·

8 min read

So you are building a mobile app using React Native. And inevitably, your designer has asked you to build six different types of bottom sheets on different screens. You pull up your favourite search engine and type in "react native bottom sheet". There it is, a 2MB library created by a kind soul for the React Native community - with words like performant and customisable in the description. Sure, you could install that and use it in your app. The only problem is, you don't need it. Not really.

In this React Native 101 guide, I am going to be walking you through the process of creating your own bottom sheet component which is fully customizable and reusable. We are going to handle dynamic height, keyboard view, pan gestures, and also some slick animations. Let's go 🚀

First things first

To make the bottom sheet truly reusable without hiccups, I prefer to use React's Context-Provider pattern. So let's quickly set up a context and provider for our bottom sheet. For that, let's create 3 files:

  • A context.tsx file for the context.

  •         import React from "react";
            import { BottomSheetRef } from "../BottomSheet";
    
            export type BottomSheetType = BottomSheetRef;
    
            const BottomSheetContext = React.createContext({} as BottomSheetType);
            export default BottomSheetContext;
    
  • A provider.tsx file for the provider. Don't forget to wrap your app inside this BottomSheetProvider component in your app's top level file.

      import React, { FC, useEffect, useRef, useState } from "react";
      import BottomSheet, { BottomSheetRef, Props } from "../BottomSheet";
      import BottomSheetContext from "./context";
    
      type PropsWithChildren = Props & {
        children: React.ReactNode;
      };
    
      const BottomSheetProvider: FC<PropsWithChildren> = ({ children, ...props }) => {
        const bottomSheetRef = useRef<BottomSheetRef>(null);
        const [refState, setRefState] = useState<BottomSheetRef>({
          show: () => {},
          hide: () => {},
        });
    
        useEffect(() => {
          setRefState(bottomSheetRef.current as BottomSheetRef);
        }, []);
    
        return (
          <BottomSheetContext.Provider value={refState}>
            {children}
            <BottomSheet ref={bottomSheetRef} {...props} />
          </BottomSheetContext.Provider>
        );
      };
    
      export default BottomSheetProvider;
    

A hook useBottomSheet.tsx for exposing the usage of the bottom sheet throughout the app.

import { useContext } from "react";
import BottomSheetContext, { BottomSheetType } from "./context";

const useBottomSheet = (): BottomSheetType => useContext(BottomSheetContext);

export default useBottomSheet;

The above 3 utility files make it easy to sprinkle your bottom sheet component all over the app wherever you want to use it. Now let's build the component - the good stuff.

Defining some types

Earlier, inside the Provider component, we passed a ref prop which was initially set to null. Inside the BottomSheet component, we will use React's forwardRef and useImperativeHandle to expose a custom object (called an imperative handle) with two functions - show and hide. All of this is pretty advanced React stuff so don't fret it doesn't make sense at first. You can read more about this on the React documentation website. For now, all you need to understand is that this is a one-time setup to allow us to expose a show and hide function on the object returned from our hook, the methods for which are defined inside the BottomSheet component.

So let's start by creating a BottomSheet.tsx file and add the following code which defines types for the ref and the props.

export type BottomSheetRef = {
  show: (params: Props) => void;
  hide: () => void;
};

export interface Props {
  animationType?: "none" | "fade" | "slide";
  height?: number;
  duration?: number;
  closeOnSwipeDown?: boolean;
  closeOnPressMask?: boolean;
  showHandler?: boolean;
  keyboardAvoidingViewEnabled?: boolean;
  children?: ReactNode;
  customStyles?: {
    wrapper?: ViewStyle;
    container?: ViewStyle;
    draggableIcon?: ViewStyle;
  };
  onClose?: () => void;
  renderContent?: () => React.ReactElement;
}

Of course, these are all the props that I would need for a level of customizability that satisfies my needs. But feel free to add any additional props that you would want inside your BottomSheet component.

I also want to define some default values for some of these props.

const defaultProps: Props = {
  animationType: "none",
  duration: 200,
  closeOnSwipeDown: true,
  closeOnPressMask: true,
  showHandler: true,
  keyboardAvoidingViewEnabled: true,
};

Creating the component

To get started, let's return the simple React Native's Modal component and define a state variable for the bottom sheet's visibility. This is the ultimate state variable which will either show or hide your bottom sheet. We will also define a helper method for toggling this state, and two methods for showing and hiding the modal by calling that toggle method.

const BottomSheet: React.ForwardRefRenderFunction<BottomSheetRef, Props> = (
  initialProps,
  ref,
) => {
  const [modalVisible, setModalVisibility] = useState(false);
  const [props, setProps] = useState<Props>({
    ...defaultProps,
    ...initialProps,
  });

  const setModalVisible = (visible: boolean) => {
    if (visible) {
      setModalVisibility(true);
    } else {
      setModalVisibility(false);
    }
  };

  const show = (params: Props) => {
    setProps({
      ...defaultProps,
      ...initialProps,
      ...params,
    });
    setModalVisible(true);
  };

  const hide = () => {
    setModalVisible(false);
  };

  useImperativeHandle(ref, () => ({
    hide,
    show,
  }));

  return (
    <Modal
      transparent
      animationType={props.animationType ?? "none"}
      visible={modalVisible}
      onRequestClose={() => {
        setModalVisible(false);
      }}></Modal>
  );
};

export default forwardRef(BottomSheet);

That should give us the basic functionality for a showable and hideable bottom sheet component. But who needs basic, right? Let's keep moving. I am going to be using Nativewind classes for styling my bottom sheet but of course, you can choose to do it however you like. I am going to use the renderContent prop to pass in children JSX for the sheet's content which can be dynamic. Edit your file to look like this.

return (
  <Modal
    transparent
    animationType={props.animationType ?? "none"}
    visible={modalVisible}
    onRequestClose={() => {
      setModalVisible(false);
    }}>
    <View className="max-h-[80%]" onLayout={handleChildrenLayout}>
      <View
        className="mb-7 mx-3 rounded-3xl overflow-hidden bg-[#181A20]"
        style={[(props.customStyles ?? {}).container]}>
        {(props.closeOnSwipeDown ?? props.showHandler) && (
          <View className="w-full items-center bg-transparent">
            <View
              className="w-12 h-1 rounded-full m-1 mt-3 bg-[#5E6272]"
              style={props.customStyles?.draggableIcon}
            />
          </View>
        )}
        <View style={{ height: props.height }}>
          {props.children ?? <View />}
          {props.renderContent && props.renderContent()}
        </View>
      </View>
    </View>
  </Modal>
);

This should give the BottomSheet a basic UI structure that is not too ugly to look at. This is all we need to create a dynamic bottom sheet. The wrapper structure is defined above and any children that you pass to it will be added inside the wrapper View component.

Adding animations

We will add two things in this section:

  • A smooth sliding animation for when the bottom sheet opens/closes.

  • A gesture to allow the user to close the bottom sheet by swiping down, and a spring animation when released.

For the sliding animation, let's create a couple of state variables. We will use React Native's Animated API along with its ValueXY() method to configure a 2D translate animation. We will also need to keep track of the current height of the bottom sheet since it is dynamic and we would need the value for the y-axis position on our animation.

const [currentHeight, setCurrentHeight] = useState(props.height ?? 260);
const [pan] = useState(
  new Animated.ValueXY({
    x: 0,
    y: currentHeight,
  }),
);

To update the current height of the sheet when it changes, we will create a function handleChildrenLayout and pass it as an onLayout prop to our wrapper View component. And then, in the visibility toggle method that we created earlier, we will trigger the animations before toggling the state.

const handleChildrenLayout = (event: LayoutChangeEvent) => {
  setCurrentHeight(event.nativeEvent.layout.height);
};

const setModalVisible = (visible: boolean) => {
  if (visible) {
    setModalVisibility(true);
    Animated.timing(pan, {
      toValue: { x: 0, y: 0 },
      duration: props.duration,
      useNativeDriver: false,
    }).start();
  } else {
    Animated.timing(pan, {
      toValue: { x: 0, y: currentHeight },
      duration: props.duration,
      useNativeDriver: false,
    }).start(() => {
      setModalVisibility(false);
    });
  }
};

You also have to change the sheet content's View component to Animated.View for it to work as intended. You also need to give it a transform: pan.getTranslateTransform() style prop.

<View className="max-h-[80%]" onLayout={handleChildrenLayout}>
    <Animated.View
       className="mb-7 mx-3 rounded-3xl overflow-hidden bg-[#181A20]"
       style={[
        { transform: pan.getTranslateTransform() },
        (props.customStyles ?? {}).container,
        ]}>
        {/* ... */}
     </Animated.View>
</View>
  • For the second part, the swipe-down gesture, we will use React Native's PanResponder to recognise the user's swipe-down gesture. Now the next bit is a bit tricky. When the bottom sheet is released after swiping down - two things can happen.

    1. The distance of the swipe was too short - in which case we would cancel the swipe and reinstate the bottom sheet's original position with a spring-like animation.

    2. The swipe distance was long enough - in which case we will go through with the closing of the bottom sheet.

To do that, let's create a panResponder object.

    const panResponder = PanResponder.create({
      onStartShouldSetPanResponder: () => !!props.closeOnSwipeDown,
      onPanResponderMove: (e, gestureState) => {
        if (gestureState.dy > 0) {
          Animated.event([null, { dy: pan.y }], { useNativeDriver: false })(
            e,
            gestureState,
          );
        }
      },
      onPanResponderRelease: (_e, gestureState) => {
        const distanceToClose = currentHeight * 0.4;

        if (gestureState.dy > distanceToClose ?? gestureState.vy > 0.5) {
          setModalVisible(false);
        } else {
          Animated.spring(pan, {
            toValue: { x: 0, y: 0 },
            useNativeDriver: false,
          }).start();
        }
      },
    });

You can see that we used currentHeight * 0.4 to check if the swipe distance was convincing enough.

Lastly, we would use this panResponder object in the UI as follows, by doing {...panResponder.panHandlers}.

    (props.closeOnSwipeDown ?? props.showHandler) && (
      <View
        {...panResponder.panHandlers}
        className="w-full items-center bg-transparent">
        <View
          className="w-12 h-1 rounded-full m-1 mt-3 bg-[#5E6272]"
          style={props.customStyles?.draggableIcon}
        />
      </View>
    );

Final touches

Two last things we want to do in our component is:

  1. Add a touch listener to close the BottomSheet if user taps anywhere on the screen outside it. To do that, add the following component inside your JSX, outside the wrapper View component, but inside the Modal.

     <TouchableOpacity
       className="flex-1 bg-transparent"
       activeOpacity={1}
       onPress={() => (props.closeOnPressMask ? hide() : {})}
     />
    
  2. Wrap the BottomSheet content inside React Native's KeyboardAvoidingView. What this does is adjust the visibility of the bottom sheet on the screen in case the device's keyboard is open. This is useful when you have TextInput fields inside the BottomSheet.

     <KeyboardAvoidingView
       enabled={props.keyboardAvoidingViewEnabled}
       behavior="padding"
       className="absolute top-0 left-0 w-full h-full bg-[#00000077]"
       style={props.customStyles?.wrapper}>
       {/* ... */}
     </KeyboardAvoidingView>
    

    That's all! We have successfully built a completely animated and customisable dynamic BottomSheet component. Let's see an example of how you would use it inside your app.

    Using the BottomSheet component

     //use the hook we created
     const bottomSheet = useBottomSheet();
    
     //show the bottom sheet by passing children
     bottomSheet.show({
       renderContent: () => {
         return (
           <View>
             <Text>This is my bottom sheet component!</Text>
             {/* ...any more children */}
           </View>
         );
       },
     });
    
     //hide the bottom sheet
     bottomSheet.hide();
    

    That was all for this week’s blog. To recap, we saw how we can use advanced React design patterns and React Native’s robust APIs to create our own customisable and reusable bottom sheet component for your mobile app.

    You can find the Github Gist for the final code for all the files below. Hope you learned something new today ✨

    https://gist.github.com/mizanxali/df7bc82a1dadf3723c15603cd385d53b