BottomDrawer
Bottom sheet with smooth slide animations and gesture-based drag to close.
9:41
signal_cellular_altwifibattery_full
Tap to open drawer
Preview Controls
Animation
Slides up from bottom with drag to close
This is a CSS-based preview of the animation. The actual React Native component uses physics-based springs for a more natural feel.
Installation
$
Usage
Example.tsx
import { useState } from 'react';import { View, Text, Button } from 'react-native';import { BottomDrawer } from './components/BottomDrawer';export default function App() {const [isOpen, setIsOpen] = useState(false);return (<View style={{ flex: 1 }}><Button title="Open Drawer" onPress={() => setIsOpen(true)} /><BottomDrawer isOpen={isOpen} onClose={() => setIsOpen(false)} height={400}><Text style={{ fontSize: 20, fontWeight: 'bold' }}>Drawer Content</Text><Text style={{ marginTop: 8, color: '#666' }}>Drag the handle down to close, or tap the backdrop.</Text></BottomDrawer></View>);}
Full Component Code
BottomDrawer.tsx
1import React, { useEffect, useCallback } from 'react';2import { View, StyleSheet, Dimensions, TouchableWithoutFeedback, ViewStyle, StyleProp } from 'react-native';3import Animated, { useSharedValue, useAnimatedStyle, withTiming, runOnJS, Easing } from 'react-native-reanimated';4import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler';56const { height: SCREEN_HEIGHT } = Dimensions.get('window');78interface BottomDrawerProps {9 isOpen: boolean;10 onClose: () => void;11 children: React.ReactNode;12 height?: number;13 duration?: number;14 handleHeight?: number;15 backdropOpacity?: number;16 style?: StyleProp<ViewStyle>;17}1819export const BottomDrawer: React.FC<BottomDrawerProps> = ({20 isOpen, onClose, children,21 height = SCREEN_HEIGHT * 0.5,22 duration = 300,23 handleHeight = 24,24 backdropOpacity = 0.5,25 style,26}) => {27 const translateY = useSharedValue(height);28 const context = useSharedValue({ y: 0 });29 const backdropVisible = useSharedValue(0);3031 const timingConfig = { duration, easing: Easing.out(Easing.ease) };3233 const closeDrawer = useCallback(() => {34 'worklet';35 translateY.value = withTiming(height, timingConfig, () => runOnJS(onClose)());36 backdropVisible.value = withTiming(0, { duration: duration / 2 });37 }, [height, duration, onClose]);3839 useEffect(() => {40 if (isOpen) {41 translateY.value = withTiming(0, timingConfig);42 backdropVisible.value = withTiming(1, { duration });43 } else {44 translateY.value = withTiming(height, timingConfig);45 backdropVisible.value = withTiming(0, { duration: duration / 2 });46 }47 }, [isOpen]);4849 const gesture = Gesture.Pan()50 .onStart(() => { context.value = { y: translateY.value }; })51 .onUpdate((e) => {52 translateY.value = Math.max(0, context.value.y + e.translationY);53 backdropVisible.value = Math.max(0, Math.min(1, 1 - translateY.value / height));54 })55 .onEnd((e) => {56 if (translateY.value > height * 0.25 || e.velocityY > 500) {57 closeDrawer();58 } else {59 translateY.value = withTiming(0, timingConfig);60 backdropVisible.value = withTiming(1, { duration: duration / 2 });61 }62 });6364 const animatedDrawerStyle = useAnimatedStyle(() => ({ transform: [{ translateY: translateY.value }] }));65 const animatedBackdropStyle = useAnimatedStyle(() => ({66 opacity: backdropVisible.value * backdropOpacity,67 pointerEvents: backdropVisible.value > 0 ? 'auto' : 'none',68 }));6970 if (!isOpen && translateY.value >= height) return null;7172 return (73 <GestureHandlerRootView style={StyleSheet.absoluteFill}>74 <TouchableWithoutFeedback onPress={onClose}>75 <Animated.View style={[styles.backdrop, animatedBackdropStyle]} />76 </TouchableWithoutFeedback>77 <Animated.View style={[styles.drawer, { height: height + handleHeight }, style, animatedDrawerStyle]}>78 <GestureDetector gesture={gesture}>79 <View style={[styles.handleContainer, { height: handleHeight }]}>80 <View style={styles.handle} />81 </View>82 </GestureDetector>83 <View style={styles.content}>{children}</View>84 </Animated.View>85 </GestureHandlerRootView>86 );87};8889const styles = StyleSheet.create({90 backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: '#000' },91 drawer: { position: 'absolute', left: 0, right: 0, bottom: 0, backgroundColor: '#fff',92 borderTopLeftRadius: 24, borderTopRightRadius: 24, elevation: 20 },93 handleContainer: { alignItems: 'center', justifyContent: 'center' },94 handle: { width: 40, height: 4, borderRadius: 2, backgroundColor: '#E0E0E0' },95 content: { flex: 1, paddingHorizontal: 16 },96});9798export default BottomDrawer;
Props
| Name | Type | Default | Description |
|---|---|---|---|
| isOpen | boolean | false | Whether the drawer is open |
| onClose | () => void | - | Callback when drawer closes |
| children | React.ReactNode | - | Drawer content |
| height | number | 50% of screen | Height of the drawer |
| duration | number | 300 | Animation duration in ms |
| handleHeight | number | 24 | Height of the drag handle area |
| backdropOpacity | number | 0.5 | Opacity of backdrop when open |
