Bottom Sheet Stack
A stack-based bottom sheet manager that lets multiple sheets layer smoothly, with automatic scaling and vertical offset to show depth, while keeping only the top sheet interactive.
Last updated on
Manual
Install the following dependencies:
npm install react-native-reanimatedCopy and paste the following code into your project.
component/templates/bottom-sheet-stack
import React, { createContext, useContext, useRef, useCallback, useState, useEffect, memo,} from "react";import { StyleSheet, ViewStyle } from "react-native";import Animated, { useSharedValue, useAnimatedStyle, withSpring,} from "react-native-reanimated";import { BottomSheetMethods } from "../bottom-sheet/types";import { SCALE_FACTOR, STACK_SPRING_CONFIG, TRANSLATE_Y_FACTOR } from "./conf";import type { IBottomSheetOptions, IBottomSheetStackContextValue, IBottomSheetStackProvider, IStackedSheet, IStackedSheetWrapper,} from "./types";const BottomSheetStackContext = createContext< IBottomSheetStackContextValue | undefined>(undefined);export const useBottomSheetStack = (): IBottomSheetStackContextValue => { const context = useContext(BottomSheetStackContext); if (!context) { throw new Error( "useBottomSheetStack must be used within BottomSheetStackProvider", ); } return context;};const StackedSheetWrapper = memo<IStackedSheetWrapper>( ({ sheet, stackIndex, totalSheets, onClose, }: IStackedSheetWrapper): React.ReactElement & React.JSX.Element => { const isTopSheet = stackIndex === totalSheets - 1; const depth = totalSheets - 1 - stackIndex; const scale = useSharedValue<number>(1); const translateY = useSharedValue<number>(0); useEffect(() => { if (isTopSheet) { scale.value = withSpring<number>(1, STACK_SPRING_CONFIG); translateY.value = withSpring<number>(0, STACK_SPRING_CONFIG); } else { scale.value = withSpring<number>( Math.pow(SCALE_FACTOR, depth), STACK_SPRING_CONFIG, ); translateY.value = withSpring<number>( depth * TRANSLATE_Y_FACTOR, STACK_SPRING_CONFIG, ); } }, [isTopSheet, depth]); const animatedStyle = useAnimatedStyle<ViewStyle>(() => ({ transform: [{ scale: scale.value }, { translateY: -translateY.value }], })); const element = React.cloneElement(sheet.component, { ref: sheet.ref, onClose: () => { sheet.onDismiss?.(); onClose(); }, dismissOnBackdropPress: true, }); return ( <Animated.View style={[styles.stackLayer, animatedStyle]} pointerEvents={isTopSheet ? "auto" : "none"} > {element} </Animated.View> ); },);StackedSheetWrapper.displayName = "StackedSheetWrapper";export const BottomSheetStackProvider: React.FC<IBottomSheetStackProvider> = memo<IBottomSheetStackProvider>( ({ children, }: IBottomSheetStackProvider): React.ReactElement & React.JSX.Element => { const [sheets, setSheets] = useState<IStackedSheet[]>([]); const idCounter = useRef(0); const pushSheet = useCallback<IBottomSheetStackContextValue["pushSheet"]>( (sheet) => { const id = `sheet-${idCounter.current++}`; const ref = React.createRef<BottomSheetMethods>(); setSheets((prev) => [ ...prev, { id, ref, component: sheet.component, onDismiss: sheet.onDismiss, }, ]); requestAnimationFrame(() => { ref.current?.snapToIndex(0); }); return id; }, [], ); const popSheet = useCallback<IBottomSheetStackContextValue["popSheet"]>( (id) => { setSheets((prev) => { if (!prev.length) return prev; if (id) { const target = prev.find((s) => s.id === id); target?.ref.current?.close(); return prev.filter((s) => s.id !== id); } const top = prev[prev.length - 1]; top.ref.current?.close(); return prev.slice(0, -1); }); }, [], ); const popToRoot = useCallback< IBottomSheetStackContextValue["popToRoot"] >(() => { setSheets((prev) => { prev.forEach((s) => s.ref.current?.close()); return []; }); }, []); const getStackDepth = useCallback(() => sheets.length, [sheets]); return ( <BottomSheetStackContext.Provider value={{ pushSheet, popSheet, popToRoot, getStackDepth, }} > {children} {sheets.map((sheet, index) => ( <StackedSheetWrapper key={sheet.id} sheet={sheet} stackIndex={index} totalSheets={sheets.length} onClose={() => popSheet(sheet.id)} /> ))} </BottomSheetStackContext.Provider> ); }, );export const useBottomSheet = < T extends IBottomSheetOptions = IBottomSheetOptions,>( options?: T,) => { const { pushSheet, popSheet } = useBottomSheetStack(); const activeId = useRef<string | null>(null); const present = useCallback( (component: React.ReactElement) => { const id = pushSheet({ component, onDismiss: options?.onDismiss, }); activeId.current = id; return id; }, [pushSheet, options?.onDismiss], ); const dismiss = useCallback(() => { if (activeId.current) { popSheet(activeId.current); activeId.current = null; } }, [popSheet]); return { present, dismiss };};const styles = StyleSheet.create({ stackLayer: { ...StyleSheet.absoluteFillObject, backgroundColor: "transparent", },});Usage
import { View, StyleSheet, Text, Pressable, TextInput } from "react-native";import { GestureHandlerRootView } from "react-native-gesture-handler";import { StatusBar } from "expo-status-bar";import { useFonts } from "expo-font";import { Feather } from "@expo/vector-icons";import { BottomSheetStackProvider, useBottomSheet,} from "@/components/templates/bottom-sheet-stack";import { BottomSheet } from "@/components/templates/bottom-sheet";const SuccessSheet = () => { const [fontLoaded] = useFonts({ SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"), HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"), }); return ( <View style={styles.successSheet}> <View style={styles.successIcon}> <Feather name="check" size={36} color="#30d158" /> </View> <Text style={[ styles.successTitle, fontLoaded && { fontFamily: "HelveticaNowDisplay" }, ]} > Saved </Text> <Text style={[ styles.successSub, fontLoaded && { fontFamily: "SfProRounded" }, ]} > Your profile has been updated </Text> </View> );};const EditSheet = () => { const { present } = useBottomSheet(); const [fontLoaded] = useFonts({ SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"), HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"), }); const handleSave = () => { present( <BottomSheet snapPoints={["35%"]} enableBackdrop={true} backgroundColor="#1c1c1e" > <SuccessSheet /> </BottomSheet>, ); }; return ( <View style={styles.editSheet}> <Text style={[ styles.editTitle, fontLoaded && { fontFamily: "HelveticaNowDisplay" }, ]} > Edit Profile </Text> <View style={styles.inputGroup}> <Text style={[ styles.inputLabel, fontLoaded && { fontFamily: "SfProRounded" }, ]} > Name </Text> <TextInput style={[styles.input, fontLoaded && { fontFamily: "SfProRounded" }]} defaultValue="John Doe" placeholderTextColor="#666" /> </View> <View style={styles.inputGroup}> <Text style={[ styles.inputLabel, fontLoaded && { fontFamily: "SfProRounded" }, ]} > Email </Text> <TextInput style={[styles.input, fontLoaded && { fontFamily: "SfProRounded" }]} defaultValue="john@example.com" placeholderTextColor="#666" keyboardType="email-address" /> </View> <Pressable style={styles.saveBtn} onPress={handleSave}> <Text style={[ styles.saveBtnText, fontLoaded && { fontFamily: "SfProRounded" }, ]} > Save Changes </Text> </Pressable> </View> );};const ProfileSheet = () => { const { present } = useBottomSheet(); const [fontLoaded] = useFonts({ SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"), HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"), }); const handleEdit = () => { present( <BottomSheet snapPoints={["55%"]} enableBackdrop={true} backgroundColor="#1c1c1e" > <EditSheet /> </BottomSheet>, ); }; 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 ( <View style={styles.sheet}> <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> <View style={styles.row}> <Pressable style={styles.rowItem} onPress={handleEdit}> <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> <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> <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> );};const AppContent = () => { const { present } = useBottomSheet(); const [fontLoaded] = useFonts({ SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"), }); const openProfile = () => { present( <BottomSheet snapPoints={["75%"]} enableBackdrop={true} backdropOpacity={0.6} backgroundColor="#1c1c1e" > <ProfileSheet /> </BottomSheet>, ); }; return ( <View style={styles.container}> <StatusBar style="light" /> <Pressable style={styles.trigger} onPress={openProfile}> <Text style={[ styles.triggerText, fontLoaded && { fontFamily: "SfProRounded" }, ]} > Edit Profile </Text> </Pressable> </View> );};export default function App() { return ( <GestureHandlerRootView style={{ flex: 1 }}> <BottomSheetStackProvider> <AppContent /> </BottomSheetStackProvider> </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, }, editSheet: { padding: 24, }, editTitle: { fontSize: 22, color: "#fff", marginBottom: 24, textAlign: "center", }, inputGroup: { marginBottom: 16, }, inputLabel: { fontSize: 13, color: "#666", marginBottom: 8, marginLeft: 4, }, input: { backgroundColor: "#2c2c2e", borderRadius: 12, paddingVertical: 14, paddingHorizontal: 16, fontSize: 16, color: "#fff", }, saveBtn: { backgroundColor: "#ffffff", paddingVertical: 16, borderRadius: 14, alignItems: "center", marginTop: 8, }, saveBtnText: { fontSize: 16, fontWeight: "600", color: "#000000", }, successSheet: { padding: 32, alignItems: "center", }, successIcon: { width: 80, height: 80, borderRadius: 40, backgroundColor: "rgba(48,209,88,0.12)", justifyContent: "center", alignItems: "center", marginBottom: 20, }, successTitle: { fontSize: 24, color: "#fff", marginBottom: 8, }, successSub: { fontSize: 15, color: "#666", },});Props
IBottomSheetStackContextValue
React Native Reanimated
