├── 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 |
--------------------------------------------------------------------------------