Building an Animated Bottom Sheet in React NativeReanimated and React Native Gesture Handler

Steve Blue
2 min readSep 4, 2023

A critical aspect of any animation-based component is state management. In this code, the offset variable is managed using Reanimated's useSharedValue. This variable represents the current position of the bottom sheet.

const offset = useSharedValue(0);

The panGesture gesture handler enables users to drag the bottom sheet up and down. It employs the Gesture.Pan() method from React Native Gesture Handler to update the offset value accordingly.

const panGesture = Gesture.Pan()
.onChange(event => {
// Update the offset based on the gesture's change in Y position.

})
.onFinalize(() => {
// Determine whether to close or open the sheet based on its final position.

});

The translateY style is responsible for animating the sheet's vertical position based on the offset value.

const translateY = useAnimatedStyle(() => {
return {
transform: [
{
translateY: offset.value,
},
],
};
}, []);

Full here:

// animated-bottom-sheet.tsx

// react
import React, {Fragment, ReactNode, useEffect} from 'react';

// modules
import {Pressable, StyleSheet} from 'react-native';
import Animated, {
FadeIn,
FadeOut,
runOnJS,
SlideInDown,
SlideOutDown,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from 'react-native-reanimated';
import {Gesture, GestureDetector} from 'react-native-gesture-handler';

type AnimatedBottomSheetProps = {
isOpen: boolean;
backdropOnPress: () => void;
children: ReactNode;
};

const PressAnimated = Animated.createAnimatedComponent(Pressable);
const HEIGHT = 300;
const CLAMP = 20;

const AnimatedBottomSheet = (props: AnimatedBottomSheetProps) => {
const offset = useSharedValue(0);

const panGesture = Gesture.Pan()
.onChange(event => {
const offsetDelta = event.changeY + offset.value;
const clamp = Math.max(-CLAMP, offsetDelta);
offset.value = offsetDelta > 0 ? offsetDelta : withSpring(clamp);
})
.onFinalize(() => {
if (offset.value < HEIGHT / 3) {
offset.value = withSpring(0);
} else {
offset.value = withTiming(HEIGHT, {}, () => {
runOnJS(props.backdropOnPress)();
});
}
});

const translateY = useAnimatedStyle(() => {
return {
transform: [
{
translateY: offset.value,
},
],
};
}, []);

useEffect(() => {
function onOpen() {
if (props.isOpen) {
offset.value = 0;
}
}

onOpen();
}, [props.isOpen]);

if (!props.isOpen) {
return <Fragment />;
}
return (
<Fragment>
<PressAnimated
onPress={props.backdropOnPress}
entering={FadeIn}
exiting={FadeOut}
style={styles.backdrop}
/>
<GestureDetector gesture={panGesture}>
<Animated.View
entering={SlideInDown.springify().damping(15)}
exiting={SlideOutDown}
style={[styles.view, translateY]}>
{props.children}
</Animated.View>
</GestureDetector>
</Fragment>
);
};

const styles = StyleSheet.create({
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.45)',
zIndex: 1,
},
view: {
backgroundColor: 'white',
height: HEIGHT,
width: '100%',
position: 'absolute',
bottom: -CLAMP * 1.1,
zIndex: 1,
borderTopLeftRadius: 10,
borderTopRightRadius: 10,
},
});

export default AnimatedBottomSheet;

use the AnimatedBottomSheet component:

function App() {
const [isOpen, setIsOpen] = useState<boolean>(false);

return (
<GestureHandlerRootView style={{flex: 1}}>
<NavigationContainer>
<SafeAreaView style={{flex: 1, justifyContent: 'center'}}>

<Button
title="open"
onPress={() => setIsOpen(prevState => !prevState)}
/>
<AnimatedBottomSheet
isOpen={isOpen}
backdropOnPress={() => setIsOpen(prevState => !prevState)}>
{/*Your component in bottom sheet*/}
</AnimatedBottomSheet>
</SafeAreaView>
</NavigationContainer>
</GestureHandlerRootView>
);
}

export default App;

--

--

Steve Blue

Experienced Mobile Application Developer with a demonstrated history of working in the computer software industry.