├── .gitignore
├── README.md
├── app.json
├── app
├── (tabs)
│ ├── _layout.tsx
│ └── index.tsx
└── _layout.tsx
├── assets
├── fonts
│ └── SpaceMono-Regular.ttf
└── images
│ ├── adaptive-icon.png
│ ├── favicon.png
│ ├── icon.png
│ ├── partial-react-logo.png
│ ├── react-logo.png
│ ├── react-logo@2x.png
│ ├── react-logo@3x.png
│ └── splash-icon.png
├── bun.lockb
├── components
└── immersive-overlay
│ ├── components
│ ├── gradient
│ │ ├── constants.ts
│ │ └── index.tsx
│ └── overlay.tsx
│ ├── index.tsx
│ ├── store.tsx
│ └── utils.ts
├── constants
└── Colors.ts
├── hooks
└── useColorScheme.ts
├── package.json
├── scripts
└── reset-project.js
└── tsconfig.json
/.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 | *.orig.*
14 | *.jks
15 | *.p8
16 | *.p12
17 | *.key
18 | *.mobileprovision
19 |
20 | # Metro
21 | .metro-health-check*
22 |
23 |
24 | ios/
25 | android/
26 |
27 | # debug
28 | npm-debug.*
29 | yarn-debug.*
30 | yarn-error.*
31 |
32 | # macOS
33 | .DS_Store
34 | *.pem
35 |
36 | # local env files
37 | .env*.local
38 |
39 | # typescript
40 | *.tsbuildinfo
41 |
42 | app-example
43 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Immersive overlay example
2 |
3 | As seen on X (thank you for the positive comments!), this is a component inspired by the apple intellgence animation, if you have an iOS device and apple intelligence enabled, long press your power button to see it in action.
4 |
5 | Not a full replica, as this component was built for my own personal needs, however I think something great came out of it.
6 |
7 | That being said, since my app is developed for iOS, I have not tested in android 😭, however, this is using well managed packages such as expo-blur, skia, and reanimated. Fingers crossed all is well.
8 |
9 | Create an expo dev build
10 |
11 | ```
12 | npm install
13 |
14 | npx expo prebuild
15 |
16 | npx expo run ios
17 |
18 | ```
19 |
--------------------------------------------------------------------------------
/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "immersive-overlay-example",
4 | "slug": "immersive-overlay-example",
5 | "version": "1.0.0",
6 | "orientation": "portrait",
7 | "icon": "./assets/images/icon.png",
8 | "scheme": "myapp",
9 | "userInterfaceStyle": "light",
10 | "newArchEnabled": true,
11 | "ios": {
12 | "supportsTablet": true,
13 | "bundleIdentifier": "com.eds2002.immersiveoverlayexample"
14 | },
15 | "android": {
16 | "adaptiveIcon": {
17 | "foregroundImage": "./assets/images/adaptive-icon.png",
18 | "backgroundColor": "#ffffff"
19 | },
20 | "package": "com.eds2002.immersiveoverlayexample"
21 | },
22 | "web": {
23 | "bundler": "metro",
24 | "output": "static",
25 | "favicon": "./assets/images/favicon.png"
26 | },
27 | "plugins": [
28 | "expo-router",
29 | [
30 | "expo-splash-screen",
31 | {
32 | "image": "./assets/images/splash-icon.png",
33 | "imageWidth": 200,
34 | "resizeMode": "contain",
35 | "backgroundColor": "#ffffff"
36 | }
37 | ]
38 | ],
39 | "experiments": {
40 | "typedRoutes": true
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/app/(tabs)/_layout.tsx:
--------------------------------------------------------------------------------
1 | import { Tabs } from "expo-router";
2 | import React from "react";
3 |
4 | import { ImmersiveOverlay } from "@/components/immersive-overlay";
5 | import { Colors } from "@/constants/Colors";
6 | import { useColorScheme } from "@/hooks/useColorScheme";
7 |
8 | export default function TabLayout() {
9 | const colorScheme = useColorScheme();
10 |
11 | return (
12 |
13 | {/*Wrapper that gives us our screen 'warp' effect */}
14 |
20 |
26 |
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/app/(tabs)/index.tsx:
--------------------------------------------------------------------------------
1 | import { useImmersiveOverlay } from "@/components/immersive-overlay/store";
2 | import { Button, Text, View } from "react-native";
3 |
4 | export default function HomeScreen() {
5 | const { immerse } = useImmersiveOverlay();
6 |
7 | const basicExampleFn = () => {
8 | immerse();
9 | };
10 |
11 | const differentPalleteExampleFn = () => {
12 | // Pallete generated by ai, wow what a crazy pallete
13 | immerse({
14 | colors: {
15 | primary: "#FF69B4",
16 | secondary: "#1E90FF",
17 | expanding: {
18 | dark: ["#FF69B4", "#DA70D6", "#1E90FF"],
19 | light: ["#FF69B4", "#DA70D6", "#1E90FF"],
20 | },
21 | },
22 | });
23 | };
24 |
25 | const withComponentExampleFn = () => {
26 | immerse({
27 | component: (
28 |
36 |
37 | dimelo
38 |
39 |
47 | Hello, Hola, Bonjour, Ciao, Hallo, Olá, Привет, こんにちは, 你好,
48 | 안녕하세요, Merhaba, Γειά σου, नमस्ते, สวัสดี, Xin chào, Salaam, Jambo,
49 | Hej, Ahoj, Aloha
50 |
51 |
52 | ),
53 | colors: {
54 | primary: "#FF0000",
55 | secondary: "#8B0000",
56 | expanding: {
57 | dark: ["#FF0000", "#B22222", "#8B0000"],
58 | light: ["#FF6347", "#DC143C", "#CD5C5C"],
59 | },
60 | },
61 | });
62 | };
63 |
64 | return (
65 |
73 |
74 |
78 |
82 |
83 | );
84 | }
85 |
--------------------------------------------------------------------------------
/app/_layout.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | DarkTheme,
3 | DefaultTheme,
4 | ThemeProvider,
5 | } from "@react-navigation/native";
6 | import { useFonts } from "expo-font";
7 | import { Stack } from "expo-router";
8 | import * as SplashScreen from "expo-splash-screen";
9 | import { StatusBar } from "expo-status-bar";
10 | import { useEffect } from "react";
11 | import "react-native-reanimated";
12 |
13 | import { useColorScheme } from "@/hooks/useColorScheme";
14 |
15 | // Prevent the splash screen from auto-hiding before asset loading is complete.
16 | SplashScreen.preventAutoHideAsync();
17 |
18 | export default function RootLayout() {
19 | const colorScheme = useColorScheme();
20 | const [loaded] = useFonts({
21 | SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
22 | });
23 |
24 | useEffect(() => {
25 | if (loaded) {
26 | SplashScreen.hideAsync();
27 | }
28 | }, [loaded]);
29 |
30 | if (!loaded) {
31 | return null;
32 | }
33 |
34 | return (
35 |
36 |
43 |
44 |
45 |
46 |
47 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/assets/fonts/SpaceMono-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/eds2002/immersive-overlay-example/6d1a5441f7b4343e84a1754aaf6b479fc1bdf939/assets/fonts/SpaceMono-Regular.ttf
--------------------------------------------------------------------------------
/assets/images/adaptive-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/eds2002/immersive-overlay-example/6d1a5441f7b4343e84a1754aaf6b479fc1bdf939/assets/images/adaptive-icon.png
--------------------------------------------------------------------------------
/assets/images/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/eds2002/immersive-overlay-example/6d1a5441f7b4343e84a1754aaf6b479fc1bdf939/assets/images/favicon.png
--------------------------------------------------------------------------------
/assets/images/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/eds2002/immersive-overlay-example/6d1a5441f7b4343e84a1754aaf6b479fc1bdf939/assets/images/icon.png
--------------------------------------------------------------------------------
/assets/images/partial-react-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/eds2002/immersive-overlay-example/6d1a5441f7b4343e84a1754aaf6b479fc1bdf939/assets/images/partial-react-logo.png
--------------------------------------------------------------------------------
/assets/images/react-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/eds2002/immersive-overlay-example/6d1a5441f7b4343e84a1754aaf6b479fc1bdf939/assets/images/react-logo.png
--------------------------------------------------------------------------------
/assets/images/react-logo@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/eds2002/immersive-overlay-example/6d1a5441f7b4343e84a1754aaf6b479fc1bdf939/assets/images/react-logo@2x.png
--------------------------------------------------------------------------------
/assets/images/react-logo@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/eds2002/immersive-overlay-example/6d1a5441f7b4343e84a1754aaf6b479fc1bdf939/assets/images/react-logo@3x.png
--------------------------------------------------------------------------------
/assets/images/splash-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/eds2002/immersive-overlay-example/6d1a5441f7b4343e84a1754aaf6b479fc1bdf939/assets/images/splash-icon.png
--------------------------------------------------------------------------------
/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/eds2002/immersive-overlay-example/6d1a5441f7b4343e84a1754aaf6b479fc1bdf939/bun.lockb
--------------------------------------------------------------------------------
/components/immersive-overlay/components/gradient/constants.ts:
--------------------------------------------------------------------------------
1 | import { Easing } from 'react-native-reanimated';
2 |
3 | export const BEZIER_EASING = Easing.bezier(0, 0.55, 0.45, 1);
4 | export const ENTERING_TIME = 750;
5 | export const EXITING_TIME = 500;
6 |
--------------------------------------------------------------------------------
/components/immersive-overlay/components/gradient/index.tsx:
--------------------------------------------------------------------------------
1 | import { Blur, Canvas, Circle } from "@shopify/react-native-skia";
2 | import { BlurView } from "expo-blur";
3 | import { Dimensions, StyleSheet, useColorScheme } from "react-native";
4 | import {
5 | Easing,
6 | interpolate,
7 | useAnimatedProps,
8 | withRepeat,
9 | withSequence,
10 | } from "react-native-reanimated";
11 | import Animated, {
12 | interpolateColor,
13 | useDerivedValue,
14 | withTiming,
15 | } from "react-native-reanimated";
16 |
17 | import { useImmersiveOverlay, useImmersiveOverlayStore } from "../../store";
18 | import { generateColors } from "../../utils";
19 | import { ENTERING_TIME, EXITING_TIME } from "./constants";
20 |
21 | const AnimatedBlurView = Animated.createAnimatedComponent(BlurView);
22 |
23 | const dimensions = Dimensions.get("window");
24 |
25 | /**Blur View Container
26 | * From my knowledege, skia can't actually blur stuff behind it's canvas, so we'll use BlurView from expo-blur
27 | */
28 | const Container = ({ children }: { children: React.ReactNode }) => {
29 | const { displayImmersiveOverlay } = useImmersiveOverlay();
30 |
31 | const animatedBlurViewProps = useAnimatedProps(() => {
32 | const duration = displayImmersiveOverlay.value
33 | ? ENTERING_TIME
34 | : EXITING_TIME;
35 |
36 | return {
37 | intensity: withTiming(displayImmersiveOverlay.value ? 25 : 0, {
38 | duration,
39 | }),
40 | };
41 | });
42 |
43 | return (
44 |
56 | {children}
57 |
58 | );
59 | };
60 |
61 | /**This is our expansion circle, starts from the bottom and expands to the top */
62 | const ExpandingCirlce = ({
63 | colors,
64 | }: { colors: ReturnType }) => {
65 | const { displayImmersiveOverlay } = useImmersiveOverlay();
66 |
67 | const expandingCircleProgress = useDerivedValue(() => {
68 | // A faster exit animation for the expanding circle
69 | const duration = displayImmersiveOverlay.value
70 | ? ENTERING_TIME
71 | : EXITING_TIME;
72 | return withTiming(displayImmersiveOverlay.value ? 1 : 0, {
73 | duration,
74 | });
75 | });
76 |
77 | const opacity = useDerivedValue(() => {
78 | return withTiming(displayImmersiveOverlay.value ? 1 : 0.5, {
79 | duration: EXITING_TIME,
80 | });
81 | });
82 |
83 | const isDark = useColorScheme() === "dark";
84 | const expandingCircleColor = useDerivedValue(() => {
85 | const colorArray = isDark ? colors.expanding.dark : colors.expanding.light;
86 |
87 | // For multiple colors, create dynamic input ranges
88 | const inputRange = colorArray.map(
89 | (_, index) => index / (colorArray.length - 1),
90 | );
91 |
92 | return interpolateColor(
93 | expandingCircleProgress.value,
94 | inputRange,
95 | colorArray,
96 | );
97 | });
98 |
99 | const expandingCircleTransform = useDerivedValue(() => {
100 | return [
101 | {
102 | scale: interpolate(expandingCircleProgress.value, [0, 1], [0, 10]),
103 | },
104 | ];
105 | });
106 |
107 | const cr = dimensions.width / 3;
108 |
109 | return (
110 |
122 |
123 |
124 | );
125 | };
126 |
127 | /**Circles (top left and bottom right) that are used to create the background gradient */
128 | const BackgroundCircles = ({
129 | colors,
130 | }: { colors: ReturnType }) => {
131 | const { displayImmersiveOverlay } = useImmersiveOverlay();
132 |
133 | const overlayVisibilityProgress = useDerivedValue(() => {
134 | return withTiming(displayImmersiveOverlay.value ? 1 : 0, {
135 | duration: displayImmersiveOverlay.value ? ENTERING_TIME : EXITING_TIME,
136 | });
137 | });
138 |
139 | const INITIAL_ANIMATION_DURATION = 1000;
140 | const REPEATED_ANIMATION_DURATION = 7500;
141 | const MIN_SCALE_FACTOR = 0.7;
142 | const MAX_SCALE_FACTOR = 1;
143 |
144 | const breathingProgress = useDerivedValue(() => {
145 | if (displayImmersiveOverlay.value) {
146 | // Initial animation from 0 to 1 (only once)
147 | return withSequence(
148 | // First animate from 0 to 1 once
149 | withTiming(1, {
150 | duration: INITIAL_ANIMATION_DURATION,
151 | easing: Easing.bezierFn(0, 0.55, 0.45, 1),
152 | }),
153 | // Then repeatedly animate between 0.7 and 1
154 | withRepeat(
155 | withSequence(
156 | // Down to 0.7
157 | withTiming(MIN_SCALE_FACTOR, {
158 | duration: REPEATED_ANIMATION_DURATION,
159 | }),
160 | // Back up to 1
161 | withTiming(MAX_SCALE_FACTOR, {
162 | duration: REPEATED_ANIMATION_DURATION,
163 | }),
164 | ),
165 | -1, // Infinite repetitions until closed
166 | true, // Don't reverse the animation
167 | ),
168 | );
169 | }
170 | // When closing, animate back to 0
171 | return withTiming(0, { duration: 500 });
172 | });
173 |
174 | const CR = dimensions.width;
175 | /**C1 */
176 | const c1Opacity = useDerivedValue(() => {
177 | return interpolate(overlayVisibilityProgress.value, [0, 1], [0, 1]);
178 | });
179 | const c1Transform = useDerivedValue(() => {
180 | return [
181 | {
182 | scale: breathingProgress.value,
183 | },
184 | ];
185 | });
186 |
187 | /**C2 */
188 | const c2Opacity = useDerivedValue(() => {
189 | return interpolate(overlayVisibilityProgress.value, [0, 1], [0, 1]);
190 | });
191 | const c2Transform = useDerivedValue(() => {
192 | return [
193 | {
194 | scale: breathingProgress.value,
195 | },
196 | ];
197 | });
198 |
199 | return (
200 | <>
201 |
213 |
214 |
215 |
216 |
228 |
229 |
230 | >
231 | );
232 | };
233 |
234 | export const Gradient = () => {
235 | const { colors } = useImmersiveOverlayStore();
236 |
237 | const generatedColors = generateColors(colors);
238 | return (
239 |
240 |
244 |
245 | );
246 | };
247 |
--------------------------------------------------------------------------------
/components/immersive-overlay/components/overlay.tsx:
--------------------------------------------------------------------------------
1 | import { StyleSheet, type ViewStyle } from "react-native";
2 | import Animated, {
3 | Easing,
4 | useAnimatedProps,
5 | useAnimatedStyle,
6 | withSpring,
7 | withTiming,
8 | } from "react-native-reanimated";
9 | import { useImmersiveOverlayStore } from "../store";
10 | import { Gradient } from "./gradient/index";
11 |
12 | const ALL_ANIMATION_DURATION = 500;
13 | const OPACITY_DURATION = 300;
14 | const EASING_BEZIER = [0.59, 0, 0.35, 1] as const;
15 |
16 | const Content = () => {
17 | const { displayImmersiveOverlay, contentComponent } =
18 | useImmersiveOverlayStore();
19 |
20 | const animatedStyles = useAnimatedStyle(() => {
21 | const duration = ALL_ANIMATION_DURATION;
22 | const easing = Easing.bezier(...EASING_BEZIER);
23 |
24 | // Define target states based on original animations
25 | const enteringState = {
26 | opacity: 1,
27 |
28 | transform: [
29 | { perspective: 1000 },
30 | { rotateX: "0deg" },
31 | { skewY: "0deg" },
32 | { scaleY: 1 },
33 | { scaleX: 1 },
34 | { translateY: 0 },
35 | ],
36 | };
37 |
38 | const exitingState = {
39 | opacity: 0,
40 | transform: [
41 | { perspective: 1000 },
42 | { rotateX: `${1 * -5}deg` },
43 | { skewY: `${-1 * 1.5}deg` },
44 | { scaleY: 1 + 1 * 1 }, // intensity = 1 for exit
45 | { scaleX: 1 - 1 * 1 * 0.6 }, // intensity = 1 for exit
46 | { translateY: 100 },
47 | ],
48 | };
49 |
50 | // Apply animations conditionally
51 | return {
52 | flex: 1,
53 | // Animation
54 | opacity: displayImmersiveOverlay.value
55 | ? withTiming(enteringState.opacity, { duration }) // Use entering duration
56 | : withTiming(exitingState.opacity, { duration: OPACITY_DURATION }), // Use specific exit opacity duration
57 |
58 | transform: [
59 | { perspective: 1000 },
60 | {
61 | rotateX: displayImmersiveOverlay.value
62 | ? // biome-ignore lint/style/noNonNullAssertion:
63 | withTiming(enteringState.transform[1]!.rotateX as string, {
64 | duration,
65 | easing,
66 | })
67 | : // biome-ignore lint/style/noNonNullAssertion:
68 | withTiming(exitingState.transform[1]!.rotateX as string, {
69 | duration,
70 | easing,
71 | }),
72 | },
73 | {
74 | skewY: displayImmersiveOverlay.value
75 | ? // biome-ignore lint/style/noNonNullAssertion:
76 | withTiming(enteringState.transform[2]!.skewY as string, {
77 | duration,
78 | easing,
79 | })
80 | : // biome-ignore lint/style/noNonNullAssertion:
81 | withTiming(exitingState.transform[2]!.skewY as string, {
82 | duration,
83 | easing,
84 | }),
85 | },
86 | {
87 | scaleY: displayImmersiveOverlay.value
88 | ? withTiming(
89 | // biome-ignore lint/style/noNonNullAssertion:
90 | enteringState.transform[3]!.scaleY as unknown as string,
91 | {
92 | duration,
93 | easing,
94 | },
95 | )
96 | : withTiming(
97 | // biome-ignore lint/style/noNonNullAssertion:
98 | exitingState.transform[3]!.scaleY as unknown as string,
99 | {
100 | duration,
101 | easing,
102 | },
103 | ),
104 | },
105 | {
106 | scaleX: displayImmersiveOverlay.value
107 | ? withTiming(
108 | // biome-ignore lint/style/noNonNullAssertion:
109 | enteringState.transform[4]!.scaleX as unknown as string,
110 | {
111 | duration,
112 | easing,
113 | },
114 | )
115 | : withTiming(
116 | // biome-ignore lint/style/noNonNullAssertion:
117 | exitingState.transform[4]!.scaleX as unknown as string,
118 | {
119 | duration,
120 | easing,
121 | },
122 | ),
123 | },
124 | {
125 | translateY: displayImmersiveOverlay.value
126 | ? withSpring(
127 | // biome-ignore lint/style/noNonNullAssertion:
128 | enteringState.transform[5]!.translateY as unknown as string,
129 | ) // Use withSpring for entering translateY
130 | : withTiming(
131 | // biome-ignore lint/style/noNonNullAssertion:
132 | exitingState.transform[5]!.translateY as unknown as string,
133 | {
134 | duration,
135 | easing,
136 | },
137 | ), // Use withTiming for exiting translateY
138 | },
139 | ],
140 | } as ViewStyle;
141 | });
142 |
143 | return (
144 |
145 | {contentComponent}
146 |
147 | );
148 | };
149 |
150 | export const Overlay = () => {
151 | const { displayImmersiveOverlay } = useImmersiveOverlayStore();
152 |
153 | const animatedRootContainerProps = useAnimatedProps(() => {
154 | return {
155 | pointerEvents: displayImmersiveOverlay.value
156 | ? ("auto" as const)
157 | : ("none" as const),
158 | };
159 | });
160 |
161 | return (
162 | {
174 | displayImmersiveOverlay.value = !displayImmersiveOverlay.value;
175 | }}
176 | animatedProps={animatedRootContainerProps}
177 | >
178 |
179 |
187 |
188 |
189 |
190 | );
191 | };
192 |
--------------------------------------------------------------------------------
/components/immersive-overlay/index.tsx:
--------------------------------------------------------------------------------
1 | import Animated, {
2 | Easing,
3 | FadeIn,
4 | FadeOut,
5 | useAnimatedStyle,
6 | useDerivedValue,
7 | withSequence,
8 | withTiming,
9 | } from "react-native-reanimated";
10 | import { Overlay } from "./components/overlay";
11 | import { useImmersiveOverlayStore } from "./store";
12 |
13 | export const ImmersiveOverlay = ({
14 | children,
15 | }: { children: React.ReactNode }) => {
16 | const { displayImmersiveOverlay } = useImmersiveOverlayStore();
17 |
18 | // This give us our 'warp' effect.
19 | const intensity = 0.1;
20 | const progress = useDerivedValue(() => {
21 | if (displayImmersiveOverlay.value) {
22 | return withSequence(
23 | withTiming(1, {
24 | duration: 300,
25 | easing: Easing.bezier(0.65, 0, 0.35, 1),
26 | }),
27 | withTiming(0, {
28 | duration: 1500,
29 | easing: Easing.bezier(0.22, 1, 0.36, 1),
30 | }),
31 | );
32 | }
33 | return withTiming(0, { duration: 300 });
34 | });
35 |
36 | const animatedStyle = useAnimatedStyle(() => {
37 | return {
38 | transform: [
39 | { rotateX: `${progress.value * -5}deg` },
40 | { skewY: `${-progress.value * 1.5}deg` },
41 | { scaleY: 1 + progress.value * intensity },
42 | { scaleX: 1 - progress.value * intensity * 0.6 },
43 | ],
44 | };
45 | });
46 |
47 | return (
48 | <>
49 | {/* This is our overlay component, the main animation */}
50 |
51 | {/* our children, where the 'warp' effect takes place. */}
52 |
63 | {children}
64 |
65 | >
66 | );
67 | };
68 |
--------------------------------------------------------------------------------
/components/immersive-overlay/store.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback } from "react";
2 | import { type SharedValue, makeMutable } from "react-native-reanimated";
3 | import { create } from "zustand";
4 |
5 | export interface ImmersiveOverlayState {
6 | displayImmersiveOverlay: SharedValue;
7 | contentComponent: React.ReactNode | null;
8 | colors: {
9 | primary: string;
10 | secondary: string;
11 | expanding: {
12 | dark: string[];
13 | light: string[];
14 | };
15 | };
16 | }
17 |
18 | const DEFAULT_COLORS = {
19 | primary: "#5465ff",
20 | secondary: "#5465ff",
21 |
22 | /**
23 | * This gives a little bit of flexibility to the colors. We keep it as an array
24 | * so that we interpolate between them. Our generateColors fn handles returning the
25 | * proper colors for the expanding circle.
26 | */
27 | expanding: {
28 | dark: ["orange", "red", "#5465ff"],
29 | light: ["orange", "red", "#0077b6"],
30 | },
31 | };
32 |
33 | // This example uses zustand, but I also assume you can use react context if you'd like.
34 | // Used zustand bc it was just convenient for me lol
35 | export const useImmersiveOverlayStore = create(() => ({
36 | // Read up on this just in case, this is how we're storing a shared value globally.
37 | //https://docs.swmansion.com/react-native-reanimated/docs/advanced/makeMutable/
38 | displayImmersiveOverlay: makeMutable(false),
39 | contentComponent: null,
40 | colors: DEFAULT_COLORS,
41 | }));
42 |
43 | // Hook
44 | export function useImmersiveOverlay() {
45 | const store = useImmersiveOverlayStore();
46 |
47 | /*** The function that allows us to add custom components, colors, and display our overlay.*/
48 | const immerse = useCallback(
49 | ({
50 | component,
51 | colors,
52 | }: {
53 | component?: React.ReactNode;
54 | colors?: typeof DEFAULT_COLORS;
55 | } = {}) => {
56 | useImmersiveOverlayStore.setState((state) => ({
57 | ...state,
58 | contentComponent: component ?? null,
59 | colors: colors ?? DEFAULT_COLORS,
60 | }));
61 |
62 | store.displayImmersiveOverlay.value = true;
63 | },
64 | [store.displayImmersiveOverlay],
65 | );
66 |
67 | // Function to dismiss the immersive overlay
68 | const dismiss = useCallback(() => {
69 | store.displayImmersiveOverlay.value = false;
70 | }, [store.displayImmersiveOverlay]);
71 |
72 | return {
73 | immerse,
74 | dismiss,
75 | displayImmersiveOverlay: store.displayImmersiveOverlay,
76 | };
77 | }
78 |
--------------------------------------------------------------------------------
/components/immersive-overlay/utils.ts:
--------------------------------------------------------------------------------
1 | import color from "tinycolor2";
2 | import type { ImmersiveOverlayState } from "./store";
3 |
4 | /**Utility function that helps us render the proper colors for the overlay */
5 | /**Note, i think this is a bit ugly, please be my guest to refactor it! */
6 |
7 | export const generateColors = (colors: ImmersiveOverlayState["colors"]) => {
8 | //The last color will be our transparent color (alpha of (0.2)), so we seperate this from the others
9 | // The reason for this is because we want this color to be our new background color, you could also opt to just completely setting it to 0.
10 | const darkColors = colors?.expanding?.dark?.slice(0, -1);
11 | const lastDarkColor =
12 | colors?.expanding?.dark?.[colors?.expanding?.dark?.length - 1];
13 |
14 | const lightColors = colors?.expanding?.light?.slice(0, -1);
15 | const lastLightColor =
16 | colors?.expanding?.light?.[colors?.expanding?.light?.length - 1];
17 |
18 | return {
19 | primary: colors?.primary || "#5465ff",
20 | secondary: colors?.secondary || "#5465ff",
21 | expanding: {
22 | dark: [
23 | // All other colors are 95% opacity, the last one is 100% opacity
24 | ...(darkColors || []).map((c) => color(c).setAlpha(0.95).toRgbString()),
25 | // Also add the last color with 100% opacity
26 | color(lastDarkColor || "#5465ff")
27 | .setAlpha(0.95)
28 | .toRgbString(),
29 | // Also add the last color with 20% opacity
30 | color(lastDarkColor || "#5465ff")
31 | .setAlpha(0.2)
32 | .toRgbString(),
33 | ],
34 | light: [
35 | ...(lightColors || []).map((c) =>
36 | color(c).setAlpha(0.95).toRgbString(),
37 | ),
38 | color(lastLightColor || "#0077b6")
39 | .setAlpha(0.95)
40 | .toRgbString(),
41 | color(lastLightColor || "#0077b6")
42 | .setAlpha(0.2)
43 | .toRgbString(),
44 | ],
45 | },
46 | };
47 | };
48 |
--------------------------------------------------------------------------------
/constants/Colors.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Below are the colors that are used in the app. The colors are defined in the light and dark mode.
3 | * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
4 | */
5 |
6 | const tintColorLight = '#0a7ea4';
7 | const tintColorDark = '#fff';
8 |
9 | export const Colors = {
10 | light: {
11 | text: '#11181C',
12 | background: '#fff',
13 | tint: tintColorLight,
14 | icon: '#687076',
15 | tabIconDefault: '#687076',
16 | tabIconSelected: tintColorLight,
17 | },
18 | dark: {
19 | text: '#ECEDEE',
20 | background: '#151718',
21 | tint: tintColorDark,
22 | icon: '#9BA1A6',
23 | tabIconDefault: '#9BA1A6',
24 | tabIconSelected: tintColorDark,
25 | },
26 | };
27 |
--------------------------------------------------------------------------------
/hooks/useColorScheme.ts:
--------------------------------------------------------------------------------
1 | export { useColorScheme } from 'react-native';
2 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "immersive-overlay-example",
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 run:android",
9 | "ios": "expo run:ios",
10 | "web": "expo start --web",
11 | "test": "jest --watchAll",
12 | "lint": "expo lint"
13 | },
14 | "jest": {
15 | "preset": "jest-expo"
16 | },
17 | "dependencies": {
18 | "@expo/vector-icons": "^14.0.2",
19 | "@react-navigation/bottom-tabs": "^7.2.0",
20 | "@react-navigation/native": "^7.0.14",
21 | "@shopify/react-native-skia": "1.5.0",
22 | "@types/tinycolor2": "^1.4.6",
23 | "expo": "~52.0.43",
24 | "expo-blur": "~14.0.3",
25 | "expo-constants": "~17.0.8",
26 | "expo-font": "~13.0.4",
27 | "expo-haptics": "~14.0.1",
28 | "expo-linking": "~7.0.5",
29 | "expo-router": "~4.0.20",
30 | "expo-splash-screen": "~0.29.22",
31 | "expo-status-bar": "~2.0.1",
32 | "expo-symbols": "~0.2.2",
33 | "expo-system-ui": "~4.0.9",
34 | "expo-web-browser": "~14.0.2",
35 | "react": "18.3.1",
36 | "react-dom": "18.3.1",
37 | "react-native": "0.76.9",
38 | "react-native-gesture-handler": "~2.20.2",
39 | "react-native-reanimated": "~3.16.1",
40 | "react-native-safe-area-context": "4.12.0",
41 | "react-native-screens": "~4.4.0",
42 | "react-native-web": "~0.19.13",
43 | "react-native-webview": "13.12.5",
44 | "tinycolor2": "^1.6.0",
45 | "zustand": "^5.0.3"
46 | },
47 | "devDependencies": {
48 | "@babel/core": "^7.25.2",
49 | "@types/jest": "^29.5.12",
50 | "@types/react": "~18.3.12",
51 | "@types/react-test-renderer": "^18.3.0",
52 | "jest": "^29.2.1",
53 | "jest-expo": "~52.0.6",
54 | "react-test-renderer": "18.3.1",
55 | "typescript": "^5.3.3"
56 | },
57 | "private": true
58 | }
59 |
--------------------------------------------------------------------------------
/scripts/reset-project.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | /**
4 | * This script is used to reset the project to a blank state.
5 | * It deletes or moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example based on user input and creates a new /app directory with an index.tsx and _layout.tsx file.
6 | * You can remove the `reset-project` script from package.json and safely delete this file after running it.
7 | */
8 |
9 | const fs = require("fs");
10 | const path = require("path");
11 | const readline = require("readline");
12 |
13 | const root = process.cwd();
14 | const oldDirs = ["app", "components", "hooks", "constants", "scripts"];
15 | const exampleDir = "app-example";
16 | const newAppDir = "app";
17 | const exampleDirPath = path.join(root, exampleDir);
18 |
19 | const indexContent = `import { Text, View } from "react-native";
20 |
21 | export default function Index() {
22 | return (
23 |
30 | Edit app/index.tsx to edit this screen.
31 |
32 | );
33 | }
34 | `;
35 |
36 | const layoutContent = `import { Stack } from "expo-router";
37 |
38 | export default function RootLayout() {
39 | return ;
40 | }
41 | `;
42 |
43 | const rl = readline.createInterface({
44 | input: process.stdin,
45 | output: process.stdout,
46 | });
47 |
48 | const moveDirectories = async (userInput) => {
49 | try {
50 | if (userInput === "y") {
51 | // Create the app-example directory
52 | await fs.promises.mkdir(exampleDirPath, { recursive: true });
53 | console.log(`📁 /${exampleDir} directory created.`);
54 | }
55 |
56 | // Move old directories to new app-example directory or delete them
57 | for (const dir of oldDirs) {
58 | const oldDirPath = path.join(root, dir);
59 | if (fs.existsSync(oldDirPath)) {
60 | if (userInput === "y") {
61 | const newDirPath = path.join(root, exampleDir, dir);
62 | await fs.promises.rename(oldDirPath, newDirPath);
63 | console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
64 | } else {
65 | await fs.promises.rm(oldDirPath, { recursive: true, force: true });
66 | console.log(`❌ /${dir} deleted.`);
67 | }
68 | } else {
69 | console.log(`➡️ /${dir} does not exist, skipping.`);
70 | }
71 | }
72 |
73 | // Create new /app directory
74 | const newAppDirPath = path.join(root, newAppDir);
75 | await fs.promises.mkdir(newAppDirPath, { recursive: true });
76 | console.log("\n📁 New /app directory created.");
77 |
78 | // Create index.tsx
79 | const indexPath = path.join(newAppDirPath, "index.tsx");
80 | await fs.promises.writeFile(indexPath, indexContent);
81 | console.log("📄 app/index.tsx created.");
82 |
83 | // Create _layout.tsx
84 | const layoutPath = path.join(newAppDirPath, "_layout.tsx");
85 | await fs.promises.writeFile(layoutPath, layoutContent);
86 | console.log("📄 app/_layout.tsx created.");
87 |
88 | console.log("\n✅ Project reset complete. Next steps:");
89 | console.log(
90 | `1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${
91 | userInput === "y"
92 | ? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
93 | : ""
94 | }`
95 | );
96 | } catch (error) {
97 | console.error(`❌ Error during script execution: ${error.message}`);
98 | }
99 | };
100 |
101 | rl.question(
102 | "Do you want to move existing files to /app-example instead of deleting them? (Y/n): ",
103 | (answer) => {
104 | const userInput = answer.trim().toLowerCase() || "y";
105 | if (userInput === "y" || userInput === "n") {
106 | moveDirectories(userInput).finally(() => rl.close());
107 | } else {
108 | console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
109 | rl.close();
110 | }
111 | }
112 | );
113 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------