├── assets ├── images │ ├── icon.png │ ├── favicon.png │ ├── react-logo.png │ ├── adaptive-icon.png │ ├── react-logo@2x.png │ ├── react-logo@3x.png │ ├── splash-icon.png │ └── partial-react-logo.png └── fonts │ └── SpaceMono-Regular.ttf ├── app ├── _layout.tsx └── index.tsx ├── constants └── index.ts ├── .vscode └── settings.json ├── eslint.config.js ├── tsconfig.json ├── .gitignore ├── README.md ├── components ├── BottomSheetViewPort.tsx ├── index.tsx └── BottomSheet.tsx ├── app.json ├── types └── index.ts ├── package.json ├── context └── index.tsx └── stylesheet └── index.ts /assets/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rit3zh/expo-stack-bottom-sheet/HEAD/assets/images/icon.png -------------------------------------------------------------------------------- /assets/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rit3zh/expo-stack-bottom-sheet/HEAD/assets/images/favicon.png -------------------------------------------------------------------------------- /assets/images/react-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rit3zh/expo-stack-bottom-sheet/HEAD/assets/images/react-logo.png -------------------------------------------------------------------------------- /assets/images/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rit3zh/expo-stack-bottom-sheet/HEAD/assets/images/adaptive-icon.png -------------------------------------------------------------------------------- /assets/images/react-logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rit3zh/expo-stack-bottom-sheet/HEAD/assets/images/react-logo@2x.png -------------------------------------------------------------------------------- /assets/images/react-logo@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rit3zh/expo-stack-bottom-sheet/HEAD/assets/images/react-logo@3x.png -------------------------------------------------------------------------------- /assets/images/splash-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rit3zh/expo-stack-bottom-sheet/HEAD/assets/images/splash-icon.png -------------------------------------------------------------------------------- /app/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { Stack } from "expo-router"; 2 | 3 | export default function RootLayout() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /assets/fonts/SpaceMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rit3zh/expo-stack-bottom-sheet/HEAD/assets/fonts/SpaceMono-Regular.ttf -------------------------------------------------------------------------------- /assets/images/partial-react-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rit3zh/expo-stack-bottom-sheet/HEAD/assets/images/partial-react-logo.png -------------------------------------------------------------------------------- /constants/index.ts: -------------------------------------------------------------------------------- 1 | import { Dimensions } from "react-native"; 2 | 3 | export const { height: SCREEN_HEIGHT } = Dimensions.get("window"); 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll": "explicit", 4 | "source.organizeImports": "explicit", 5 | "source.sortMembers": "explicit" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // https://docs.expo.dev/guides/using-eslint/ 2 | const { defineConfig } = require('eslint/config'); 3 | const expoConfig = require('eslint-config-expo/flat'); 4 | 5 | module.exports = defineConfig([ 6 | expoConfig, 7 | { 8 | ignores: ['dist/*'], 9 | }, 10 | ]); 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "expo/tsconfig.base", 3 | "compilerOptions": { 4 | "strict": true, 5 | "paths": { 6 | "@/*": [ 7 | "./*" 8 | ] 9 | } 10 | }, 11 | "include": [ 12 | "**/*.ts", 13 | "**/*.tsx", 14 | ".expo/types/**/*.ts", 15 | "expo-env.d.ts" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files 2 | 3 | # dependencies 4 | node_modules/ 5 | 6 | # Expo 7 | .expo/ 8 | dist/ 9 | web-build/ 10 | expo-env.d.ts 11 | 12 | # Native 13 | .kotlin/ 14 | *.orig.* 15 | *.jks 16 | *.p8 17 | *.p12 18 | *.key 19 | *.mobileprovision 20 | 21 | # Metro 22 | .metro-health-check* 23 | 24 | # debug 25 | npm-debug.* 26 | yarn-debug.* 27 | yarn-error.* 28 | 29 | # macOS 30 | .DS_Store 31 | *.pem 32 | 33 | # local env files 34 | .env*.local 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | 39 | app-example 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🧊 Expo Bottom Sheet Stack 2 | 3 | A sleek, **stackable bottom sheet** built with [Reanimated 3](https://docs.swmansion.com/react-native-reanimated/). Inspired by iOS modal sheets with dark themeing. 4 | 5 | https://github.com/user-attachments/assets/f11c214c-b642-46e5-bd2a-9603880d228c 6 | 7 | --- 8 | 9 | ## 🚀 Usage 10 | 11 | ```bash 12 | git clone https://github.com/rit3zh/expo-bottom-sheet-stack 13 | cd expo-bottom-sheet-stack 14 | pnpm install 15 | pnpm start 16 | ``` 17 | 18 | --- 19 | 20 | ## 📦 Install 21 | 22 | ```bash 23 | pnpm add react-native-reanimated react-native-gesture-handler expo-linear-gradient 24 | ``` 25 | 26 | --- 27 | 28 | ## 🔧 Setup 29 | 30 | - Enable `react-native-reanimated/plugin` in `babel.config.js` 31 | - Wrap root with `GestureHandlerRootView` 32 | 33 | --- 34 | 35 | ## ⚛️ Built with 36 | 37 | - React Native + Expo 38 | - Reanimated 3 39 | - Expo + Expo Symbols 40 | -------------------------------------------------------------------------------- /components/BottomSheetViewPort.tsx: -------------------------------------------------------------------------------- 1 | import { useBottomSheet } from "@/context/index"; 2 | import React from "react"; 3 | import { StyleSheet, View } from "react-native"; 4 | import { BottomSheet } from "./BottomSheet"; 5 | 6 | export const BottomSheetViewport: React.FC = () => { 7 | const { bottomSheets } = useBottomSheet(); 8 | 9 | return ( 10 | 11 | {bottomSheets.map((sheet, index) => ( 12 | 18 | ))} 19 | 20 | ); 21 | }; 22 | 23 | const styles = StyleSheet.create({ 24 | viewport: { 25 | position: "absolute", 26 | top: 0, 27 | left: 0, 28 | right: 0, 29 | bottom: 0, 30 | zIndex: 9999, 31 | pointerEvents: "box-none", 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "expo-stack-bottom-sheet", 4 | "slug": "expo-stack-bottom-sheet", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "icon": "./assets/images/icon.png", 8 | "scheme": "expostackbottomsheet", 9 | "userInterfaceStyle": "automatic", 10 | "newArchEnabled": true, 11 | "ios": { 12 | "supportsTablet": true 13 | }, 14 | "android": { 15 | "adaptiveIcon": { 16 | "foregroundImage": "./assets/images/adaptive-icon.png", 17 | "backgroundColor": "#ffffff" 18 | }, 19 | "edgeToEdgeEnabled": true 20 | }, 21 | "web": { 22 | "bundler": "metro", 23 | "output": "static", 24 | "favicon": "./assets/images/favicon.png" 25 | }, 26 | "plugins": [ 27 | "expo-router", 28 | [ 29 | "expo-splash-screen", 30 | { 31 | "image": "./assets/images/splash-icon.png", 32 | "imageWidth": 200, 33 | "resizeMode": "contain", 34 | "backgroundColor": "#ffffff" 35 | } 36 | ] 37 | ], 38 | "experiments": { 39 | "typedRoutes": true 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /types/index.ts: -------------------------------------------------------------------------------- 1 | export type BottomSheetSize = "small" | "medium" | "large" | "full"; 2 | 3 | export interface BottomSheetAction { 4 | label: string; 5 | onPress?: () => void; 6 | variant?: "default" | "primary" | "destructive"; 7 | dismissOnPress?: boolean; 8 | } 9 | 10 | export interface BottomSheetOptions { 11 | size?: BottomSheetSize; 12 | title?: string; 13 | showCloseButton?: boolean; 14 | dismissOnBackdrop?: boolean; 15 | duration?: number; 16 | scrollable?: boolean; 17 | onClose?: () => void; 18 | actions?: BottomSheetAction[]; 19 | } 20 | 21 | export interface BottomSheet { 22 | id: string; 23 | content: any; 24 | options: Required; 25 | shouldDismiss?: boolean; 26 | } 27 | 28 | export interface BottomSheetContextValue { 29 | bottomSheets: BottomSheet[]; 30 | show: ( 31 | content: React.ReactNode | string, 32 | options?: BottomSheetOptions 33 | ) => string; 34 | update: ( 35 | id: string, 36 | content: React.ReactNode | string, 37 | options?: BottomSheetOptions 38 | ) => void; 39 | dismiss: (id: string) => void; 40 | dismissAll: () => void; 41 | } 42 | 43 | export interface BottomSheetProps { 44 | children: React.ReactNode; 45 | } 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "expo-stack-bottom-sheet", 3 | "main": "expo-router/entry", 4 | "version": "1.0.0", 5 | "scripts": { 6 | "start": "expo start", 7 | "reset-project": "node ./scripts/reset-project.js", 8 | "android": "expo start --android", 9 | "ios": "expo start --ios", 10 | "web": "expo start --web", 11 | "lint": "expo lint" 12 | }, 13 | "dependencies": { 14 | "@expo/vector-icons": "^14.1.0", 15 | "@gorhom/bottom-sheet": "^5.1.8", 16 | "@react-navigation/bottom-tabs": "^7.3.10", 17 | "@react-navigation/elements": "^2.3.8", 18 | "@react-navigation/native": "^7.1.6", 19 | "expo": "~53.0.20", 20 | "expo-blur": "~14.1.5", 21 | "expo-constants": "~17.1.7", 22 | "expo-font": "~13.3.2", 23 | "expo-haptics": "~14.1.4", 24 | "expo-image": "~2.4.0", 25 | "expo-linear-gradient": "^14.1.5", 26 | "expo-linking": "~7.1.7", 27 | "expo-router": "~5.1.4", 28 | "expo-splash-screen": "~0.30.10", 29 | "expo-status-bar": "~2.2.3", 30 | "expo-symbols": "~0.4.5", 31 | "expo-system-ui": "~5.0.10", 32 | "expo-web-browser": "~14.2.0", 33 | "react": "19.0.0", 34 | "react-dom": "19.0.0", 35 | "react-native": "0.79.5", 36 | "react-native-gesture-handler": "~2.24.0", 37 | "react-native-reanimated": "~3.17.4", 38 | "react-native-safe-area-context": "5.4.0", 39 | "react-native-screens": "~4.11.1", 40 | "react-native-web": "~0.20.0", 41 | "react-native-webview": "13.13.5" 42 | }, 43 | "devDependencies": { 44 | "@babel/core": "^7.25.2", 45 | "@types/react": "~19.0.10", 46 | "eslint": "^9.25.0", 47 | "eslint-config-expo": "~9.2.0", 48 | "typescript": "~5.8.3" 49 | }, 50 | "private": true 51 | } 52 | -------------------------------------------------------------------------------- /context/index.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | BottomSheet, 3 | BottomSheetContextValue, 4 | BottomSheetOptions, 5 | } from "@/types/index"; 6 | import React, { createContext, useCallback, useContext, useState } from "react"; 7 | 8 | const DEFAULT_BOTTOM_SHEET_OPTIONS: Required = { 9 | size: "medium", 10 | title: "", 11 | scrollable: false, 12 | showCloseButton: true, 13 | dismissOnBackdrop: true, 14 | duration: 0, 15 | onClose: () => {}, 16 | actions: [], 17 | }; 18 | 19 | const BottomSheetContext = createContext( 20 | undefined 21 | ); 22 | 23 | export const useBottomSheet = (): BottomSheetContextValue => { 24 | const context = useContext(BottomSheetContext); 25 | if (!context) { 26 | throw new Error("useBottomSheet must be used within a BottomSheetProvider"); 27 | } 28 | return context; 29 | }; 30 | 31 | export const BottomSheetProvider: React.FC<{ children: React.ReactNode }> = ({ 32 | children, 33 | }) => { 34 | const [bottomSheets, setBottomSheets] = useState([]); 35 | 36 | const show = useCallback( 37 | ( 38 | content: React.ReactNode | string, 39 | options?: BottomSheetOptions 40 | ): string => { 41 | const id = Math.random().toString(36).substring(2, 9); 42 | const bottomSheet: BottomSheet = { 43 | id, 44 | content, 45 | options: { 46 | ...DEFAULT_BOTTOM_SHEET_OPTIONS, 47 | ...options, 48 | }, 49 | }; 50 | setBottomSheets((prevSheets) => [...prevSheets, bottomSheet]); 51 | return id; 52 | }, 53 | [] 54 | ); 55 | 56 | const update = useCallback( 57 | ( 58 | id: string, 59 | content: React.ReactNode | string, 60 | options?: BottomSheetOptions 61 | ) => { 62 | setBottomSheets((prevSheets) => 63 | prevSheets.map((sheet) => 64 | sheet.id === id 65 | ? { 66 | ...sheet, 67 | content, 68 | options: { 69 | ...sheet.options, 70 | ...options, 71 | }, 72 | } 73 | : sheet 74 | ) 75 | ); 76 | }, 77 | [] 78 | ); 79 | 80 | const dismiss = useCallback((id: string) => { 81 | setBottomSheets((prevSheets) => 82 | prevSheets.filter((sheet) => sheet.id !== id) 83 | ); 84 | }, []); 85 | 86 | const dismissAll = useCallback(() => { 87 | setBottomSheets([]); 88 | }, []); 89 | 90 | const value: BottomSheetContextValue = { 91 | bottomSheets, 92 | show, 93 | update, 94 | dismiss, 95 | dismissAll, 96 | }; 97 | 98 | return ( 99 | 100 | {children} 101 | 102 | ); 103 | }; 104 | -------------------------------------------------------------------------------- /components/index.tsx: -------------------------------------------------------------------------------- 1 | import { BottomSheetProvider, useBottomSheet } from "@/context/index"; 2 | import type { BottomSheetOptions, BottomSheetProps } from "@/types/index"; 3 | import * as React from "react"; 4 | import { BottomSheetViewport } from "./BottomSheetViewPort"; 5 | 6 | type BottomSheetRef = { 7 | show?: ( 8 | content: React.ReactNode | string, 9 | options?: BottomSheetOptions 10 | ) => string; 11 | update?: ( 12 | id: string, 13 | content: React.ReactNode | string, 14 | options?: BottomSheetOptions 15 | ) => void; 16 | dismiss?: (id: string) => void; 17 | dismissAll?: () => void; 18 | }; 19 | 20 | const bottomSheetRef: BottomSheetRef = {}; 21 | 22 | const BottomSheetController: React.FC = () => { 23 | const bottomSheet = useBottomSheet(); 24 | bottomSheetRef.show = bottomSheet.show; 25 | bottomSheetRef.update = bottomSheet.update; 26 | bottomSheetRef.dismiss = bottomSheet.dismiss; 27 | bottomSheetRef.dismissAll = bottomSheet.dismissAll; 28 | return null; 29 | }; 30 | 31 | export const BottomSheetProviderWithViewport: React.FC = ({ 32 | children, 33 | }) => { 34 | return ( 35 | <> 36 | 37 | 38 | {children} 39 | 40 | 41 | 42 | ); 43 | }; 44 | 45 | export const BottomSheet = { 46 | show: ( 47 | content: React.ReactNode | string, 48 | options?: BottomSheetOptions 49 | ): string => { 50 | if (!bottomSheetRef.show) { 51 | console.warn( 52 | "BottomSheet provider not initialized. Make sure you have wrapped your app with BottomSheetProviderWithViewport." 53 | ); 54 | return ""; 55 | } 56 | return bottomSheetRef.show(content, options); 57 | }, 58 | update: ( 59 | id: string, 60 | content: React.ReactNode | string, 61 | options?: BottomSheetOptions 62 | ): void => { 63 | if (!bottomSheetRef.update) { 64 | console.warn( 65 | "BottomSheet provider not initialized. Make sure you have wrapped your app with BottomSheetProviderWithViewport." 66 | ); 67 | return; 68 | } 69 | return bottomSheetRef.update(id, content, options); 70 | }, 71 | dismiss: (id: string): void => { 72 | if (!bottomSheetRef.dismiss) { 73 | console.warn( 74 | "BottomSheet provider not initialized. Make sure you have wrapped your app with BottomSheetProviderWithViewport." 75 | ); 76 | return; 77 | } 78 | return bottomSheetRef.dismiss(id); 79 | }, 80 | dismissAll: (): void => { 81 | if (!bottomSheetRef.dismissAll) { 82 | console.warn( 83 | "BottomSheet provider not initialized. Make sure you have wrapped your app with BottomSheetProviderWithViewport." 84 | ); 85 | return; 86 | } 87 | return bottomSheetRef.dismissAll(); 88 | }, 89 | }; 90 | 91 | export { BottomSheetProvider, useBottomSheet } from "@/context/index"; 92 | export type { 93 | BottomSheetAction, 94 | BottomSheetOptions, 95 | BottomSheetSize, 96 | } from "@/types/index"; 97 | -------------------------------------------------------------------------------- /stylesheet/index.ts: -------------------------------------------------------------------------------- 1 | import { Platform, StyleSheet } from "react-native"; 2 | 3 | export const bottomSheetStyle = StyleSheet.create({ 4 | backdrop: { 5 | position: "absolute", 6 | top: 0, 7 | left: 0, 8 | right: 0, 9 | bottom: 0, 10 | backgroundColor: "rgba(0, 0, 0, 0.5)", 11 | zIndex: 999, 12 | }, 13 | backdropPressable: { 14 | flex: 1, 15 | }, 16 | sheetContainer: { 17 | position: "absolute", 18 | left: 16, 19 | right: 16, 20 | borderRadius: 24, 21 | overflow: "hidden", 22 | }, 23 | sheet: { 24 | flex: 1, 25 | borderRadius: 24, 26 | backgroundColor: "#000000", 27 | }, 28 | handle: { 29 | width: 40, 30 | height: 4, 31 | backgroundColor: "#333333", 32 | borderRadius: 2, 33 | alignSelf: "center", 34 | marginTop: 12, 35 | marginBottom: 8, 36 | }, 37 | header: { 38 | flexDirection: "row", 39 | alignItems: "center", 40 | justifyContent: "space-between", 41 | paddingHorizontal: 24, 42 | paddingVertical: 16, 43 | borderBottomWidth: 1, 44 | borderBottomColor: "rgba(255, 255, 255, 0.1)", 45 | }, 46 | title: { 47 | color: "#FFFFFF", 48 | fontSize: 18, 49 | fontWeight: "600", 50 | }, 51 | closeButton: { 52 | width: 32, 53 | height: 32, 54 | borderRadius: 16, 55 | backgroundColor: "rgba(255, 255, 255, 0.1)", 56 | alignItems: "center", 57 | justifyContent: "center", 58 | }, 59 | closeButtonText: { 60 | color: "#999999", 61 | fontSize: 16, 62 | fontWeight: "500", 63 | }, 64 | contentContainer: { 65 | flex: 1, 66 | flexDirection: "column", 67 | }, 68 | content: { 69 | flex: 1, 70 | paddingHorizontal: 24, 71 | paddingVertical: 20, 72 | }, 73 | customContent: { 74 | flex: 1, 75 | }, 76 | scrollView: { 77 | flex: 1, 78 | }, 79 | scrollContent: { 80 | flexGrow: 1, 81 | }, 82 | text: { 83 | color: "#CCCCCC", 84 | fontSize: 16, 85 | lineHeight: 24, 86 | }, 87 | actions: { 88 | flexShrink: 0, 89 | flexDirection: "row", 90 | paddingHorizontal: 24, 91 | paddingTop: 16, 92 | paddingBottom: Platform.OS === "ios" ? 34 : 20, 93 | gap: 12, 94 | borderTopWidth: 1, 95 | borderTopColor: "rgba(255, 255, 255, 0.1)", 96 | backgroundColor: "rgba(0, 0, 0, 0.98)", 97 | minHeight: 80, 98 | }, 99 | actionButton: { 100 | flex: 1, 101 | backgroundColor: "#1A1A1A", 102 | borderRadius: 12, 103 | paddingVertical: 14, 104 | paddingHorizontal: 16, 105 | alignItems: "center", 106 | borderWidth: 1, 107 | borderColor: "#333333", 108 | }, 109 | primaryButton: { 110 | backgroundColor: "#3B82F6", 111 | borderColor: "#3B82F6", 112 | }, 113 | destructiveButton: { 114 | backgroundColor: "rgba(239, 68, 68, 0.1)", 115 | borderColor: "#EF4444", 116 | }, 117 | actionButtonText: { 118 | color: "#FFFFFF", 119 | fontSize: 16, 120 | fontWeight: "500", 121 | }, 122 | primaryButtonText: { 123 | color: "#FFFFFF", 124 | }, 125 | destructiveButtonText: { 126 | color: "#EF4444", 127 | }, 128 | }); 129 | -------------------------------------------------------------------------------- /components/BottomSheet.tsx: -------------------------------------------------------------------------------- 1 | import { SCREEN_HEIGHT } from "@/constants"; 2 | import { useBottomSheet } from "@/context"; 3 | import { bottomSheetStyle as styles } from "@/stylesheet/index"; 4 | import type { BottomSheet as BottomSheetType } from "@/types"; 5 | import React, { useEffect, useRef, useState } from "react"; 6 | import { 7 | PanResponder, 8 | Pressable, 9 | ScrollView, 10 | Text, 11 | TouchableOpacity, 12 | View, 13 | } from "react-native"; 14 | import Animated, { 15 | Easing, 16 | runOnJS, 17 | SharedValue, 18 | useAnimatedStyle, 19 | useSharedValue, 20 | withSpring, 21 | withTiming, 22 | } from "react-native-reanimated"; 23 | 24 | interface BottomSheetProps { 25 | bottomSheet: BottomSheetType; 26 | index: number; 27 | totalSheets: number; 28 | sharedBackdropOpacity?: SharedValue; 29 | } 30 | 31 | export const BottomSheet: React.FC = ({ 32 | bottomSheet, 33 | index, 34 | totalSheets, 35 | sharedBackdropOpacity, 36 | }) => { 37 | const { dismiss } = useBottomSheet(); 38 | const isDismissing = useRef(false); 39 | const prevIndexRef = useRef(-1); 40 | const isVisible = useRef(false); 41 | 42 | const [isScrolling, setIsScrolling] = useState(false); 43 | const scrollOffset = useRef(0); 44 | const isContentScrollable = useRef(false); 45 | 46 | const translateY = useSharedValue(SCREEN_HEIGHT); 47 | const opacity = useSharedValue(0); 48 | const scale = useSharedValue(0.95); 49 | 50 | const backdropOpacity = sharedBackdropOpacity || useSharedValue(0); 51 | 52 | const getStackOffset = () => { 53 | const baseOffset = 20; 54 | const maxOffset = 60; 55 | const offset = Math.min(index * baseOffset, maxOffset); 56 | return offset; 57 | }; 58 | 59 | const getStackScale = () => { 60 | const scaleReduction = 0.085; 61 | const minScale = 0.22; 62 | return Math.max(1 - index * scaleReduction, minScale); 63 | }; 64 | 65 | const getInitialPosition = () => { 66 | const basePosition = SCREEN_HEIGHT * 0.35; 67 | 68 | switch (bottomSheet.options.size) { 69 | case "small": 70 | return basePosition + SCREEN_HEIGHT * 0.2_8 + getStackOffset(); 71 | case "medium": 72 | return basePosition + SCREEN_HEIGHT * 0.1_1_5 + getStackOffset(); 73 | case "large": 74 | return basePosition - SCREEN_HEIGHT * 0.082 - getStackOffset(); 75 | case "full": 76 | return SCREEN_HEIGHT * 0.05 + getStackOffset(); 77 | default: 78 | return basePosition + getStackOffset(); 79 | } 80 | }; 81 | 82 | const getSheetHeight = () => { 83 | switch (bottomSheet.options.size) { 84 | case "small": 85 | return SCREEN_HEIGHT * 0.35; 86 | case "medium": 87 | return SCREEN_HEIGHT * 0.5; 88 | case "large": 89 | return SCREEN_HEIGHT * 0.7; 90 | case "full": 91 | return SCREEN_HEIGHT * 0.85; 92 | default: 93 | return SCREEN_HEIGHT * 0.5; 94 | } 95 | }; 96 | 97 | const dismissWithAnimation = () => { 98 | if (isDismissing.current || !isVisible.current) return; 99 | isDismissing.current = true; 100 | isVisible.current = false; 101 | 102 | opacity.value = withTiming(0, { 103 | duration: 350, 104 | easing: Easing.out(Easing.quad), 105 | }); 106 | 107 | translateY.value = withTiming(SCREEN_HEIGHT + 100, { 108 | duration: 400, 109 | easing: Easing.out(Easing.quad), 110 | }); 111 | 112 | scale.value = withTiming(0.8, { 113 | duration: 350, 114 | easing: Easing.out(Easing.quad), 115 | }); 116 | 117 | if (totalSheets === 1) { 118 | backdropOpacity.value = withTiming(0, { 119 | duration: 350, 120 | easing: Easing.out(Easing.quad), 121 | }); 122 | } 123 | 124 | setTimeout(() => { 125 | runOnJS(() => { 126 | dismiss(bottomSheet.id); 127 | bottomSheet.options.onClose?.(); 128 | })(); 129 | }, 400); 130 | }; 131 | 132 | useEffect(() => { 133 | if ( 134 | prevIndexRef.current !== index && 135 | prevIndexRef.current !== -1 && 136 | isVisible.current 137 | ) { 138 | const newPosition = getInitialPosition(); 139 | const newScale = getStackScale(); 140 | 141 | translateY.value = withTiming(newPosition + 15, { 142 | duration: 250, 143 | easing: Easing.out(Easing.quad), 144 | }); 145 | 146 | scale.value = withTiming(newScale * 0.96, { 147 | duration: 250, 148 | easing: Easing.out(Easing.quad), 149 | }); 150 | 151 | setTimeout(() => { 152 | if (isVisible.current) { 153 | translateY.value = withSpring(newPosition, { 154 | damping: 30, 155 | stiffness: 250, 156 | mass: 0.6, 157 | }); 158 | 159 | scale.value = withSpring(newScale, { 160 | damping: 30, 161 | stiffness: 250, 162 | mass: 0.6, 163 | }); 164 | } 165 | }, 120); 166 | } 167 | 168 | prevIndexRef.current = index; 169 | }, [index]); 170 | 171 | const panResponder = useRef( 172 | PanResponder.create({ 173 | onMoveShouldSetPanResponder: (evt, gestureState) => { 174 | if (index !== 0) return false; 175 | 176 | const isDraggingDown = gestureState.dy > 0; 177 | const isSignificantVerticalMove = Math.abs(gestureState.dy) > 8; 178 | const isVerticalSwipe = 179 | Math.abs(gestureState.dy) > Math.abs(gestureState.dx) * 1.5; 180 | 181 | if (isScrolling) return false; 182 | 183 | if ( 184 | isContentScrollable.current && 185 | scrollOffset.current > 0 && 186 | isDraggingDown 187 | ) { 188 | return false; 189 | } 190 | 191 | return isDraggingDown && isSignificantVerticalMove && isVerticalSwipe; 192 | }, 193 | onPanResponderGrant: () => { 194 | if (index !== 0) return; 195 | }, 196 | onPanResponderMove: (evt, gestureState) => { 197 | if (index !== 0 || !isVisible.current) return; 198 | 199 | if ( 200 | gestureState.dy > 0 && 201 | (!isContentScrollable.current || scrollOffset.current <= 0) 202 | ) { 203 | const currentPosition = getInitialPosition(); 204 | const newY = currentPosition + gestureState.dy; 205 | translateY.value = Math.max(newY, currentPosition); 206 | } 207 | }, 208 | onPanResponderRelease: (evt, gestureState) => { 209 | if (index !== 0) return; 210 | 211 | const shouldDismiss = gestureState.dy > 100 || gestureState.vy > 1.2; 212 | 213 | if (shouldDismiss) { 214 | dismissWithAnimation(); 215 | } else { 216 | translateY.value = withSpring(getInitialPosition(), { 217 | damping: 25, 218 | stiffness: 400, 219 | mass: 0.6, 220 | }); 221 | } 222 | }, 223 | }) 224 | ).current; 225 | 226 | useEffect(() => { 227 | const delay = index * 80; 228 | 229 | const timer = setTimeout(() => { 230 | isVisible.current = true; 231 | 232 | if (backdropOpacity.value === 0) { 233 | backdropOpacity.value = withTiming(1, { 234 | duration: 350, 235 | easing: Easing.out(Easing.quad), 236 | }); 237 | } 238 | 239 | opacity.value = withTiming(1, { 240 | duration: 400, 241 | easing: Easing.out(Easing.quad), 242 | }); 243 | 244 | translateY.value = withSpring(getInitialPosition(), { 245 | damping: 28, 246 | stiffness: 220, 247 | mass: 0.8, 248 | }); 249 | 250 | scale.value = withSpring(getStackScale(), { 251 | damping: 28, 252 | stiffness: 220, 253 | mass: 0.8, 254 | }); 255 | }, delay); 256 | 257 | if (bottomSheet.options.duration && bottomSheet.options.duration > 0) { 258 | const autoTimer = setTimeout(() => { 259 | dismissWithAnimation(); 260 | }, bottomSheet.options.duration + delay); 261 | 262 | return () => { 263 | clearTimeout(timer); 264 | clearTimeout(autoTimer); 265 | }; 266 | } 267 | 268 | return () => clearTimeout(timer); 269 | }, [bottomSheet]); 270 | 271 | const animatedStyle = useAnimatedStyle(() => ({ 272 | opacity: opacity.value, 273 | transform: [{ translateY: translateY.value }, { scale: scale.value }], 274 | zIndex: 1000 - index, 275 | })); 276 | 277 | const backdropStyle = useAnimatedStyle(() => ({ 278 | opacity: backdropOpacity.value, 279 | })); 280 | 281 | const sheetHeight = getSheetHeight(); 282 | 283 | const renderContent = () => { 284 | const contentElement = 285 | typeof bottomSheet.content === "string" ? ( 286 | {bottomSheet.content} 287 | ) : ( 288 | bottomSheet.content 289 | ); 290 | 291 | const content = bottomSheet.content.props as any; 292 | const hasScrollView = 293 | React.isValidElement(bottomSheet.content) && 294 | (bottomSheet.content.type === ScrollView || 295 | (content?.children && 296 | React.Children.toArray(content?.children).some( 297 | (child) => React.isValidElement(child) && child.type === ScrollView 298 | ))); 299 | 300 | if (hasScrollView) { 301 | isContentScrollable.current = true; 302 | return {contentElement}; 303 | } 304 | 305 | const shouldWrapInScroll = 306 | bottomSheet.options?.scrollable !== false && 307 | (bottomSheet.options.size === "large" || 308 | bottomSheet.options.size === "full"); 309 | 310 | if (shouldWrapInScroll) { 311 | isContentScrollable.current = true; 312 | return ( 313 | { 320 | scrollOffset.current = event.nativeEvent.contentOffset.y; 321 | }} 322 | onScrollBeginDrag={() => setIsScrolling(true)} 323 | onScrollEndDrag={() => setIsScrolling(false)} 324 | onMomentumScrollEnd={() => setIsScrolling(false)} 325 | > 326 | {contentElement} 327 | 328 | ); 329 | } 330 | 331 | return contentElement; 332 | }; 333 | 334 | return ( 335 | <> 336 | {index === 0 && ( 337 | 338 | { 341 | if (bottomSheet.options.dismissOnBackdrop !== false) { 342 | dismissWithAnimation(); 343 | } 344 | }} 345 | /> 346 | 347 | )} 348 | 349 | 364 | 365 | 366 | 367 | {bottomSheet.options.title && ( 368 | 369 | {bottomSheet.options.title} 370 | {bottomSheet.options.showCloseButton !== false && ( 371 | 375 | 376 | 377 | )} 378 | 379 | )} 380 | 381 | 382 | {renderContent()} 383 | 384 | {bottomSheet.options.actions && 385 | bottomSheet.options.actions.length > 0 && ( 386 | 387 | {bottomSheet.options.actions.map((action, actionIndex) => ( 388 | { 397 | action.onPress?.(); 398 | if (action.dismissOnPress !== false) { 399 | dismissWithAnimation(); 400 | } 401 | }} 402 | > 403 | 412 | {action.label} 413 | 414 | 415 | ))} 416 | 417 | )} 418 | 419 | 420 | 421 | 422 | ); 423 | }; 424 | 425 | export const BottomSheetContainer: React.FC<{ 426 | sheets: BottomSheetType[]; 427 | }> = ({ sheets }) => { 428 | const sharedBackdropOpacity = useSharedValue(0); 429 | 430 | return ( 431 | <> 432 | {sheets.map((sheet, index) => ( 433 | 440 | ))} 441 | 442 | ); 443 | }; 444 | -------------------------------------------------------------------------------- /app/index.tsx: -------------------------------------------------------------------------------- 1 | import { BottomSheet, BottomSheetProviderWithViewport } from "@/components"; 2 | import { Feather } from "@expo/vector-icons"; 3 | import { Stack } from "expo-router"; 4 | import { 5 | Appearance, 6 | Dimensions, 7 | SafeAreaView, 8 | ScrollView, 9 | StyleSheet, 10 | Text, 11 | TouchableOpacity, 12 | View, 13 | } from "react-native"; 14 | import { GestureHandlerRootView } from "react-native-gesture-handler"; 15 | 16 | const { width: SCREEN_WIDTH } = Dimensions.get("window"); 17 | 18 | Appearance.setColorScheme("dark"); 19 | 20 | export default function Index() { 21 | const TaskDetailsContent = () => ( 22 | 23 | 24 | 25 | HIGH PRIORITY 26 | 27 | 2h left 28 | 29 | 30 | Complete Presentation 31 | 32 | Finalize Q4 slides and prepare notes for tomorrow's meeting with 33 | stakeholders. 34 | 35 | 36 | 37 | Tasks 38 | {[ 39 | { text: "Review data", done: true }, 40 | { text: "Update metrics", done: true }, 41 | { text: "Add achievements", done: false }, 42 | { text: "Prepare Q&A", done: false }, 43 | ].map((task, index) => ( 44 | 45 | 48 | {task.done && } 49 | 50 | 53 | {task.text} 54 | 55 | 56 | ))} 57 | 58 | 59 | ); 60 | 61 | const CalendarContent = () => { 62 | const days = ["S", "M", "T", "W", "T", "F", "S"]; 63 | const dates = Array.from({ length: 35 }, (_, i) => i - 2); 64 | 65 | return ( 66 | 67 | 68 | 69 | 70 | 71 | December 2024 72 | 73 | 74 | 75 | 76 | 77 | 78 | {days.map((day, index) => ( 79 | 80 | {day} 81 | 82 | ))} 83 | 84 | 85 | 86 | {dates.map((date, index) => { 87 | const actualDate = 88 | date <= 0 ? 30 + date : date > 31 ? date - 31 : date; 89 | const isOutside = date <= 0 || date > 31; 90 | const isToday = actualDate === 15; 91 | const hasEvent = [8, 15, 22].includes(actualDate); 92 | 93 | return ( 94 | 98 | 105 | {actualDate} 106 | 107 | {hasEvent && !isOutside && } 108 | 109 | ); 110 | })} 111 | 112 | 113 | 114 | Today 115 | {[ 116 | { time: "10:00", title: "Team Sync" }, 117 | { time: "14:00", title: "Review" }, 118 | { time: "16:30", title: "Planning" }, 119 | ].map((event, index) => ( 120 | 121 | {event.time} 122 | {event.title} 123 | 124 | ))} 125 | 126 | 127 | ); 128 | }; 129 | 130 | const ProjectsContent = () => ( 131 | 132 | 133 | 134 | 12 135 | Active 136 | 137 | 138 | 89% 139 | Complete 140 | 141 | 142 | 3 143 | Review 144 | 145 | 146 | 147 | Projects 148 | {[ 149 | { name: "Mobile Redesign", progress: 75 }, 150 | { name: "API Integration", progress: 45 }, 151 | { name: "Performance", progress: 90 }, 152 | ].map((project, index) => ( 153 | 154 | 155 | {project.name} 156 | 157 | {project.progress}% 158 | 159 | 160 | 161 | 167 | 168 | 169 | ))} 170 | 171 | ); 172 | 173 | const SuccessContent = () => ( 174 | 175 | 176 | 177 | 178 | Completed 179 | Task marked as done 180 | 181 | ); 182 | 183 | const NestedSheetContent = () => ( 184 | 185 | Settings 186 | 187 | {["Notifications", "Privacy", "Account", "Help"].map((option) => ( 188 | { 192 | BottomSheet.show( 193 | <> 194 | 195 | {option} Settings 196 | 197 | 198 | Configure your {option.toLowerCase()} preferences here. 199 | 200 | , 201 | { 202 | title: option, 203 | dismissOnBackdrop: true, 204 | } 205 | ); 206 | }} 207 | > 208 | {option} 209 | 210 | 211 | ))} 212 | 213 | 214 | ); 215 | 216 | const showTaskSheet = () => { 217 | BottomSheet.show(, { 218 | title: "Task", 219 | size: "large", 220 | actions: [ 221 | { 222 | label: "Complete", 223 | variant: "primary", 224 | onPress: () => { 225 | BottomSheet.show(, { 226 | size: "small", 227 | duration: 2000, 228 | }); 229 | }, 230 | dismissOnPress: false, 231 | }, 232 | { 233 | label: "Options", 234 | onPress: () => { 235 | BottomSheet.show(, { 236 | title: "Options", 237 | size: "medium", 238 | }); 239 | }, 240 | dismissOnPress: false, 241 | }, 242 | ], 243 | }); 244 | }; 245 | 246 | const showCalendarSheet = () => { 247 | BottomSheet.show(, { 248 | title: "Calendar", 249 | size: "large", 250 | actions: [ 251 | { 252 | label: "Today", 253 | variant: "primary", 254 | onPress: () => {}, 255 | dismissOnPress: false, 256 | }, 257 | ], 258 | }); 259 | }; 260 | 261 | const showProjectsSheet = () => { 262 | BottomSheet.show(, { 263 | title: "Projects", 264 | size: "medium", 265 | actions: [ 266 | { 267 | label: "View All", 268 | dismissOnPress: false, 269 | onPress: () => { 270 | BottomSheet.show( 271 | 272 | 273 | All projects will be shown here 274 | 275 | , 276 | { 277 | title: "All Projects", 278 | size: "large", 279 | } 280 | ); 281 | }, 282 | }, 283 | { 284 | label: "Show Tasks", 285 | dismissOnPress: false, 286 | onPress: () => { 287 | BottomSheet.show(, { 288 | title: "Task", 289 | size: "large", 290 | actions: [ 291 | { 292 | label: "Complete", 293 | variant: "primary", 294 | 295 | onPress: () => { 296 | BottomSheet.show(, { 297 | size: "small", 298 | }); 299 | }, 300 | dismissOnPress: false, 301 | }, 302 | { 303 | label: "Options", 304 | dismissOnPress: false, 305 | onPress: () => { 306 | BottomSheet.show(, { 307 | title: "Options", 308 | size: "medium", 309 | }); 310 | }, 311 | }, 312 | ], 313 | }); 314 | }, 315 | }, 316 | ], 317 | }); 318 | }; 319 | 320 | return ( 321 | <> 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | Friday, Dec 15 330 | Dashboard 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 342 | 24 343 | Tasks 344 | 345 | 349 | 5 350 | Projects 351 | 352 | 356 | 3 357 | Meetings 358 | 359 | 360 | 89% 361 | Done 362 | 363 | 364 | 365 | 366 | Quick Actions 367 | 368 | 372 | 373 | New Task 374 | 375 | 379 | 380 | Schedule 381 | 382 | 386 | 387 | Projects 388 | 389 | 390 | 391 | Reports 392 | 393 | 394 | 395 | 396 | 397 | Recent 398 | 399 | {[ 400 | { title: "Design Review", time: "10:00" }, 401 | { title: "Team Standup", time: "11:30" }, 402 | { title: "Documentation", time: "14:00" }, 403 | ].map((item, index) => ( 404 | 409 | 410 | {item.title} 411 | {item.time} 412 | 413 | 414 | 415 | ))} 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | ); 424 | } 425 | 426 | const styles = StyleSheet.create({ 427 | container: { 428 | flex: 1, 429 | backgroundColor: "#000", 430 | }, 431 | content: { 432 | flex: 1, 433 | }, 434 | header: { 435 | flexDirection: "row", 436 | justifyContent: "space-between", 437 | alignItems: "center", 438 | paddingHorizontal: 20, 439 | paddingTop: 50, 440 | paddingBottom: 30, 441 | }, 442 | label: { 443 | color: "#666", 444 | fontSize: 12, 445 | marginBottom: 4, 446 | }, 447 | title: { 448 | color: "#FFF", 449 | fontSize: 32, 450 | fontWeight: "700", 451 | }, 452 | menu: { 453 | width: 40, 454 | height: 40, 455 | borderRadius: 20, 456 | backgroundColor: "#111", 457 | alignItems: "center", 458 | justifyContent: "center", 459 | }, 460 | statsGrid: { 461 | flexDirection: "row", 462 | flexWrap: "wrap", 463 | paddingHorizontal: 16, 464 | gap: 8, 465 | }, 466 | statCard: { 467 | width: (SCREEN_WIDTH - 32 - 8) / 2, 468 | backgroundColor: "#111", 469 | padding: 20, 470 | borderRadius: 12, 471 | borderWidth: 1, 472 | borderColor: "#222", 473 | }, 474 | statNumber: { 475 | color: "#FFF", 476 | fontSize: 28, 477 | fontWeight: "700", 478 | }, 479 | statLabel: { 480 | color: "#666", 481 | fontSize: 12, 482 | marginTop: 4, 483 | }, 484 | section: { 485 | paddingHorizontal: 20, 486 | marginTop: 30, 487 | }, 488 | sectionTitle: { 489 | color: "#FFF", 490 | fontSize: 16, 491 | fontWeight: "600", 492 | marginBottom: 16, 493 | }, 494 | actions: { 495 | flexDirection: "row", 496 | flexWrap: "wrap", 497 | gap: 10, 498 | }, 499 | actionCard: { 500 | width: (SCREEN_WIDTH - 40 - 10) / 2, 501 | backgroundColor: "#111", 502 | padding: 20, 503 | borderRadius: 12, 504 | alignItems: "center", 505 | gap: 8, 506 | borderWidth: 1, 507 | borderColor: "#222", 508 | }, 509 | actionText: { 510 | color: "#999", 511 | fontSize: 12, 512 | }, 513 | listItem: { 514 | flexDirection: "row", 515 | justifyContent: "space-between", 516 | alignItems: "center", 517 | backgroundColor: "#111", 518 | padding: 16, 519 | borderRadius: 8, 520 | marginBottom: 8, 521 | borderWidth: 1, 522 | borderColor: "#1A1A1A", 523 | }, 524 | listTitle: { 525 | color: "#FFF", 526 | fontSize: 14, 527 | fontWeight: "500", 528 | }, 529 | listTime: { 530 | color: "#666", 531 | fontSize: 12, 532 | marginTop: 2, 533 | }, 534 | bottomNav: { 535 | position: "absolute", 536 | bottom: 0, 537 | left: 0, 538 | right: 0, 539 | flexDirection: "row", 540 | justifyContent: "space-around", 541 | alignItems: "center", 542 | backgroundColor: "#111", 543 | paddingVertical: 16, 544 | paddingBottom: 32, 545 | borderTopWidth: 1, 546 | borderTopColor: "#222", 547 | }, 548 | navItem: { 549 | padding: 8, 550 | }, 551 | navCenter: { 552 | width: 40, 553 | height: 40, 554 | borderRadius: 20, 555 | backgroundColor: "#FFF", 556 | alignItems: "center", 557 | justifyContent: "center", 558 | }, 559 | }); 560 | 561 | const taskStyles = StyleSheet.create({ 562 | container: { 563 | padding: 4, 564 | }, 565 | header: { 566 | flexDirection: "row", 567 | justifyContent: "space-between", 568 | alignItems: "center", 569 | marginBottom: 20, 570 | }, 571 | badge: { 572 | backgroundColor: "#222", 573 | paddingHorizontal: 10, 574 | paddingVertical: 4, 575 | borderRadius: 4, 576 | }, 577 | badgeText: { 578 | color: "#FFF", 579 | fontSize: 10, 580 | fontWeight: "600", 581 | letterSpacing: 1, 582 | }, 583 | time: { 584 | color: "#666", 585 | fontSize: 12, 586 | }, 587 | title: { 588 | color: "#FFF", 589 | fontSize: 24, 590 | fontWeight: "600", 591 | marginBottom: 8, 592 | }, 593 | description: { 594 | color: "#999", 595 | fontSize: 14, 596 | lineHeight: 20, 597 | marginBottom: 24, 598 | }, 599 | section: { 600 | marginBottom: 20, 601 | }, 602 | sectionTitle: { 603 | color: "#FFF", 604 | fontSize: 14, 605 | fontWeight: "600", 606 | marginBottom: 12, 607 | }, 608 | taskItem: { 609 | flexDirection: "row", 610 | alignItems: "center", 611 | gap: 12, 612 | paddingVertical: 8, 613 | }, 614 | checkbox: { 615 | width: 18, 616 | height: 18, 617 | borderRadius: 4, 618 | borderWidth: 1.5, 619 | borderColor: "#333", 620 | alignItems: "center", 621 | justifyContent: "center", 622 | }, 623 | checked: { 624 | backgroundColor: "#FFF", 625 | borderColor: "#FFF", 626 | }, 627 | checkmark: { 628 | width: 10, 629 | height: 10, 630 | backgroundColor: "#000", 631 | borderRadius: 2, 632 | }, 633 | taskText: { 634 | color: "#CCC", 635 | fontSize: 14, 636 | }, 637 | taskDone: { 638 | color: "#666", 639 | textDecorationLine: "line-through", 640 | }, 641 | }); 642 | 643 | const calendarStyles = StyleSheet.create({ 644 | container: { 645 | padding: 4, 646 | }, 647 | nav: { 648 | flexDirection: "row", 649 | justifyContent: "space-between", 650 | alignItems: "center", 651 | marginBottom: 24, 652 | }, 653 | month: { 654 | color: "#FFF", 655 | fontSize: 16, 656 | fontWeight: "600", 657 | }, 658 | weekDays: { 659 | flexDirection: "row", 660 | justifyContent: "space-around", 661 | marginBottom: 16, 662 | }, 663 | weekDay: { 664 | color: "#666", 665 | fontSize: 11, 666 | fontWeight: "600", 667 | width: 40, 668 | textAlign: "center", 669 | }, 670 | dates: { 671 | flexDirection: "row", 672 | flexWrap: "wrap", 673 | marginBottom: 24, 674 | }, 675 | date: { 676 | width: "14.28%", 677 | aspectRatio: 1, 678 | alignItems: "center", 679 | justifyContent: "center", 680 | position: "relative", 681 | }, 682 | today: { 683 | backgroundColor: "#FFF", 684 | borderRadius: 8, 685 | }, 686 | dateText: { 687 | color: "#999", 688 | fontSize: 14, 689 | }, 690 | outsideText: { 691 | color: "#333", 692 | }, 693 | todayText: { 694 | color: "#000", 695 | fontWeight: "600", 696 | }, 697 | dot: { 698 | width: 3, 699 | height: 3, 700 | borderRadius: 2, 701 | backgroundColor: "#666", 702 | position: "absolute", 703 | bottom: 8, 704 | }, 705 | events: { 706 | borderTopWidth: 1, 707 | borderTopColor: "#222", 708 | paddingTop: 16, 709 | }, 710 | eventsTitle: { 711 | color: "#FFF", 712 | fontSize: 14, 713 | fontWeight: "600", 714 | marginBottom: 12, 715 | }, 716 | event: { 717 | flexDirection: "row", 718 | gap: 16, 719 | paddingVertical: 10, 720 | borderBottomWidth: 1, 721 | borderBottomColor: "#1A1A1A", 722 | }, 723 | eventTime: { 724 | color: "#666", 725 | fontSize: 12, 726 | width: 45, 727 | }, 728 | eventTitle: { 729 | color: "#CCC", 730 | fontSize: 14, 731 | flex: 1, 732 | }, 733 | }); 734 | 735 | const projectStyles = StyleSheet.create({ 736 | container: { 737 | padding: 4, 738 | }, 739 | stats: { 740 | flexDirection: "row", 741 | gap: 12, 742 | marginBottom: 24, 743 | }, 744 | stat: { 745 | flex: 1, 746 | backgroundColor: "#111", 747 | padding: 16, 748 | borderRadius: 8, 749 | alignItems: "center", 750 | borderWidth: 1, 751 | borderColor: "#222", 752 | }, 753 | statValue: { 754 | color: "#FFF", 755 | fontSize: 20, 756 | fontWeight: "700", 757 | }, 758 | statLabel: { 759 | color: "#666", 760 | fontSize: 10, 761 | marginTop: 4, 762 | }, 763 | title: { 764 | color: "#FFF", 765 | fontSize: 14, 766 | fontWeight: "600", 767 | marginBottom: 12, 768 | }, 769 | project: { 770 | marginBottom: 16, 771 | }, 772 | projectInfo: { 773 | flexDirection: "row", 774 | justifyContent: "space-between", 775 | marginBottom: 8, 776 | }, 777 | projectName: { 778 | color: "#FFF", 779 | fontSize: 14, 780 | }, 781 | projectProgress: { 782 | color: "#666", 783 | fontSize: 12, 784 | }, 785 | progressBar: { 786 | height: 2, 787 | backgroundColor: "#222", 788 | borderRadius: 1, 789 | overflow: "hidden", 790 | }, 791 | progressFill: { 792 | height: "100%", 793 | backgroundColor: "#FFF", 794 | }, 795 | }); 796 | 797 | const successStyles = StyleSheet.create({ 798 | container: { 799 | alignItems: "center", 800 | padding: 20, 801 | }, 802 | icon: { 803 | width: 60, 804 | height: 60, 805 | borderRadius: 30, 806 | backgroundColor: "#111", 807 | alignItems: "center", 808 | justifyContent: "center", 809 | marginBottom: 16, 810 | }, 811 | title: { 812 | color: "#FFF", 813 | fontSize: 18, 814 | fontWeight: "600", 815 | marginBottom: 4, 816 | }, 817 | message: { 818 | color: "#666", 819 | fontSize: 14, 820 | }, 821 | }); 822 | 823 | const nestedStyles = StyleSheet.create({ 824 | container: { 825 | padding: 4, 826 | }, 827 | title: { 828 | color: "#FFF", 829 | fontSize: 16, 830 | fontWeight: "600", 831 | marginBottom: 20, 832 | }, 833 | options: { 834 | gap: 4, 835 | }, 836 | option: { 837 | flexDirection: "row", 838 | justifyContent: "space-between", 839 | alignItems: "center", 840 | paddingVertical: 16, 841 | paddingHorizontal: 4, 842 | borderBottomWidth: 1, 843 | borderBottomColor: "#1A1A1A", 844 | }, 845 | optionText: { 846 | color: "#CCC", 847 | fontSize: 14, 848 | }, 849 | }); 850 | --------------------------------------------------------------------------------