diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index b803a418e..a553b14c5 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -38,6 +38,7 @@
"react-dom": "19.3.0",
"react-native": "0.88.0-rc.1",
"react-native-drawer-layout": "^4.2.10",
+ "react-native-ease": "0.8.0",
"react-native-gesture-handler": "~3.3.0",
"react-native-mmkv": "4.3.2",
"react-native-nitro-modules": "0.37.1",
diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx
index 2888b3292..20d3bad4d 100644
--- a/apps/mobile/src/app/_layout.tsx
+++ b/apps/mobile/src/app/_layout.tsx
@@ -74,61 +74,62 @@ function RootLayoutContent() {
return (
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/apps/mobile/src/components/splash-overlay.tsx b/apps/mobile/src/components/splash-overlay.tsx
index 782f8a790..aa9d7ddc3 100644
--- a/apps/mobile/src/components/splash-overlay.tsx
+++ b/apps/mobile/src/components/splash-overlay.tsx
@@ -1,17 +1,9 @@
import { Image } from 'expo-image';
import * as SplashScreen from 'expo-splash-screen';
-import { useEffect, useState } from 'react';
-import { Appearance, StyleSheet } from 'react-native';
-import Animated, {
- Easing,
- ReduceMotion,
- useAnimatedStyle,
- useReducedMotion,
- useSharedValue,
- withDelay,
- withSequence,
- withTiming,
-} from 'react-native-reanimated';
+import { useEffect, useMemo, useState, type ReactNode } from 'react';
+import { Appearance, StyleSheet, useWindowDimensions, View } from 'react-native';
+import { EaseView } from 'react-native-ease';
+import { useReducedMotion } from 'react-native-reanimated';
import { colors } from '@/design/tokens';
@@ -19,28 +11,50 @@ const ICON = require('@/assets/images/icon.png');
/** `imageWidth` of the `expo-splash-screen` config in app.config.ts. */
const ICON_SIZE = 120;
+/**
+ * icon.png draws a 96 pt purple square with a 22 pt radius. The flood starts 1 pt inside it so the icon hides it
+ * completely on the first frame.
+ */
+const FLOOD_SIZE = 94;
+const FLOOD_RADIUS = 21;
+/**
+ * icon.png's white letter circle is 66 pt wide. Android clips without anti-aliasing, so the clip keeps a purple margin
+ * and its edge falls on purple.
+ */
+const LETTERS_CLIP = 72;
+const COLUMNS = 10;
-const SQUEEZE_MS = 140;
-const BURST_MS = 520;
+const FLOOD_MS = 350;
+/** Core Animation replaces a 0 duration with its 0.25 s default. */
+const CUT_MS = 1;
+const RIPPLE_DELAY_MS = 100;
+const RIPPLE_SPREAD_MS = 300;
+const PIXEL_MS = 160;
+const RIPPLE_END_MS = FLOOD_MS + RIPPLE_DELAY_MS + RIPPLE_SPREAD_MS + PIXEL_MS;
+const POP_MS = 90;
+const SHRINK_MS = 130;
+const SETTLE_DELAY_MS = FLOOD_MS + 50;
+const SETTLE_MS = 600;
const CROSSFADE_MS = 250;
+/** The native animations start once this render reaches the UI thread, after the unmount timer starts. */
+const UNMOUNT_SLACK_MS = 50;
const DISPLAY_TIMEOUT_MS = 300;
let played = false;
/**
- * Draws the native splash in JS and hides the native one once this frame is on screen, then plays the dismiss once
- * per app process: the icon squeezes, then bursts toward the viewer while a ring pulses out and the background clears.
- * Reduce Motion gets a crossfade.
+ * Wraps the app, draws the native splash over it, and hides the native splash once this frame is on screen. Then it
+ * plays the dismiss once per app process: the icon's purple square floods the screen, the letters pop and shrink, and
+ * the purple breaks into pixels that vanish outward from the logo while the app settles into place. Reduce Motion gets
+ * a crossfade.
*/
-export function SplashOverlay() {
+export function SplashOverlay({ children }: { children: ReactNode }) {
const [visible, setVisible] = useState(!played);
const [scheme] = useState<'light' | 'dark'>(() => (Appearance.getColorScheme() === 'dark' ? 'dark' : 'light'));
const reduceMotion = useReducedMotion();
const [displayed, setDisplayed] = useState(false);
- const backdrop = useSharedValue(1);
- const iconScale = useSharedValue(1);
- const iconOpacity = useSharedValue(1);
- const ring = useSharedValue(0);
+ const [replayed] = useState(played);
+ const { width, height } = useWindowDimensions();
useEffect(() => {
if (!visible) return;
@@ -50,58 +64,129 @@ export function SplashOverlay() {
}
played = true;
SplashScreen.hide();
- if (reduceMotion) {
- const crossfade = { duration: CROSSFADE_MS, reduceMotion: ReduceMotion.Never };
- backdrop.set(withTiming(0, crossfade));
- iconOpacity.set(withTiming(0, crossfade));
- } else {
- iconScale.set(
- withSequence(
- withTiming(0.9, { duration: SQUEEZE_MS, easing: Easing.out(Easing.quad) }),
- withTiming(4, { duration: BURST_MS, easing: Easing.in(Easing.cubic) }),
- ),
- );
- iconOpacity.set(withDelay(SQUEEZE_MS + BURST_MS * 0.3, withTiming(0, { duration: BURST_MS * 0.7 })));
- ring.set(withDelay(SQUEEZE_MS, withTiming(1, { duration: BURST_MS, easing: Easing.out(Easing.cubic) })));
- backdrop.set(withDelay(SQUEEZE_MS, withTiming(0, { duration: BURST_MS, easing: Easing.inOut(Easing.quad) })));
- }
- const timeout = setTimeout(() => setVisible(false), reduceMotion ? CROSSFADE_MS : SQUEEZE_MS + BURST_MS);
+ const timeout = setTimeout(
+ () => setVisible(false),
+ (reduceMotion ? CROSSFADE_MS : RIPPLE_END_MS) + UNMOUNT_SLACK_MS,
+ );
return () => clearTimeout(timeout);
- }, [visible, displayed, reduceMotion, backdrop, iconScale, iconOpacity, ring]);
+ }, [visible, displayed, reduceMotion]);
- const backdropStyle = useAnimatedStyle(() => ({ opacity: backdrop.get() }));
- const iconStyle = useAnimatedStyle(() => ({
- opacity: iconOpacity.get(),
- transform: [{ scale: iconScale.get() }, { rotate: `${(iconScale.get() - 1) * -4}deg` }],
- }));
- const ringStyle = useAnimatedStyle(() => ({
- opacity: ring.get() === 0 ? 0 : 0.5 * (1 - ring.get()),
- transform: [{ scale: 0.6 + ring.get() * 6 }],
- }));
+ const started = replayed || displayed;
+ const grid = useMemo(() => pixelGrid(width, height), [width, height]);
+ const ripple = started && !reduceMotion;
+ const floodScale = (Math.max(width, height) + 4 * grid.size) / FLOOD_SIZE;
- if (!visible) return null;
return (
-
-
-
-
-
- setDisplayed(true)} />
-
-
-
+ <>
+
+ {children}
+
+ {visible && (
+
+ {!reduceMotion && (
+
+ {grid.cells.map((cell) => (
+
+ ))}
+
+ )}
+
+
+
+ setDisplayed(true)} />
+
+
+
+
+
+
+
+ )}
+ >
);
}
+function pixelGrid(width: number, height: number) {
+ const size = width / COLUMNS;
+ const rows = (Math.ceil(height / 2 / size) + 1) * 2;
+ const gridHeight = rows * size;
+ const maxDistance = Math.hypot(width / 2, gridHeight / 2);
+ const cells = [];
+ for (let row = 0; row < rows; row++) {
+ for (let column = 0; column < COLUMNS; column++) {
+ const left = column * size;
+ const top = row * size;
+ const distance = Math.hypot(left + size / 2 - width / 2, top + size / 2 - gridHeight / 2);
+ const delay = Math.round(FLOOD_MS + RIPPLE_DELAY_MS + (distance / maxDistance) * RIPPLE_SPREAD_MS);
+ cells.push({ key: `${row}:${column}`, left, top, delay });
+ }
+ }
+ return { size, width, height: gridHeight, cells };
+}
+
const styles = StyleSheet.create({
+ app: { flex: 1 },
center: { alignItems: 'center', justifyContent: 'center' },
+ grid: { position: 'absolute' },
icon: { width: ICON_SIZE, height: ICON_SIZE },
- ring: {
- position: 'absolute',
- width: ICON_SIZE * 0.8,
- height: ICON_SIZE * 0.8,
- borderRadius: ICON_SIZE * 0.4,
- borderWidth: 4,
- borderColor: colors.light.brand,
+ flood: { position: 'absolute', width: FLOOD_SIZE, height: FLOOD_SIZE, backgroundColor: colors.light.brand },
+ pixel: { position: 'absolute', backgroundColor: colors.light.brand },
+ letters: { position: 'absolute', width: LETTERS_CLIP, height: LETTERS_CLIP },
+ lettersClip: { width: LETTERS_CLIP, height: LETTERS_CLIP, borderRadius: LETTERS_CLIP / 2, overflow: 'hidden' },
+ lettersIcon: {
+ width: ICON_SIZE,
+ height: ICON_SIZE,
+ marginLeft: (LETTERS_CLIP - ICON_SIZE) / 2,
+ marginTop: (LETTERS_CLIP - ICON_SIZE) / 2,
},
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 383f4e375..a95cff044 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -147,7 +147,7 @@ importers:
version: 58.0.7(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.0-preview.7)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0)
'@sentry/react-native':
specifier: 8.28.0
- version: 8.28.0(672b379b6e7e68e5b9eeed6245d458db)
+ version: 8.28.0(9457479bd38008a58fa8d0b0ce8065f8)
expo:
specifier: ~58.0.0-preview.7
version: 58.0.0-preview.7(@babel/core@7.29.7(supports-color@8.1.1))(@expo/dom-webview@58.0.1)(@expo/metro-runtime@58.0.7)(expo-router@58.0.8)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0)(supports-color@8.1.1)(typescript@6.0.3)
@@ -208,6 +208,9 @@ importers:
react-native-drawer-layout:
specifier: ^4.2.10
version: 4.2.10(b2eb7b3ef08c15606ffa7158704edab4)
+ react-native-ease:
+ specifier: 0.8.0
+ version: 0.8.0(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0)
react-native-gesture-handler:
specifier: ~3.3.0
version: 3.3.0(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0)
@@ -9476,6 +9479,19 @@ packages:
react-native-gesture-handler: '>= 2.0.0'
react-native-reanimated: '>= 2.0.0'
+ react-native-ease@0.8.0:
+ resolution: {integrity: sha512-V77tfBOPt8RXZIPY+/VQYqp0OTE1n6Wp9kNaauAIHUxGZ39q3jMd3d2VJLrjH4u/r+/+dZbBdONCmV1pdEtq2Q==}
+ peerDependencies:
+ nativewind: '>=4'
+ react: '*'
+ react-native: '*'
+ uniwind: '>=1'
+ peerDependenciesMeta:
+ nativewind:
+ optional: true
+ uniwind:
+ optional: true
+
react-native-gesture-handler@3.3.0:
resolution: {integrity: sha512-ZCTlJ09nE3GPbm7J74lvwpxmx1TQ/7MDlHZNLARVYTN82k21mJUJ7BmI9eWjEi8eevHiAudI5O1Cpnt0rJfoJA==}
peerDependencies:
@@ -15697,17 +15713,15 @@ snapshots:
'@sentry/replay': 10.75.2
'@sentry/replay-canvas': 10.75.2
- '@sentry/bundler-plugins@10.75.2(supports-color@8.1.1)(webpack@5.109.2(@swc/core@1.16.1)(@swc/html@1.16.1)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26))':
+ '@sentry/bundler-plugins@10.75.2':
dependencies:
'@babel/core': 7.29.7(supports-color@8.1.1)
- '@sentry/cli': 2.58.6(supports-color@8.1.1)
+ '@sentry/cli': 2.58.6
'@sentry/core': 10.75.2
dotenv: 17.4.2
find-up: 5.0.0
glob: 13.0.6
magic-string: 0.30.21
- optionalDependencies:
- webpack: 5.109.2(@swc/core@1.16.1)(@swc/html@1.16.1)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)
transitivePeerDependencies:
- encoding
- supports-color
@@ -15760,7 +15774,7 @@ snapshots:
'@sentry/cli-win32-x64@3.8.0':
optional: true
- '@sentry/cli@2.58.6(supports-color@8.1.1)':
+ '@sentry/cli@2.58.6':
dependencies:
https-proxy-agent: 5.0.1(supports-color@8.1.1)
node-fetch: 2.7.0
@@ -15813,10 +15827,10 @@ snapshots:
dependencies:
'@sentry/core': 10.75.2
- '@sentry/react-native@8.28.0(672b379b6e7e68e5b9eeed6245d458db)':
+ '@sentry/react-native@8.28.0(9457479bd38008a58fa8d0b0ce8065f8)':
dependencies:
'@sentry/browser': 10.75.2
- '@sentry/bundler-plugins': 10.75.2(supports-color@8.1.1)(webpack@5.109.2(@swc/core@1.16.1)(@swc/html@1.16.1)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26))
+ '@sentry/bundler-plugins': 10.75.2
'@sentry/cli': 3.8.0
'@sentry/core': 10.75.2
'@sentry/expo-upload-sourcemaps': 8.28.0(@expo/env@2.5.0(supports-color@8.1.1))(dotenv@17.4.2)
@@ -15830,7 +15844,7 @@ snapshots:
'@expo/env': 2.5.0(supports-color@8.1.1)
dotenv: 17.4.2
expo: 58.0.0-preview.7(@babel/core@7.29.7(supports-color@8.1.1))(@expo/dom-webview@58.0.1)(@expo/metro-runtime@58.0.7)(expo-router@58.0.8)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0)(supports-color@8.1.1)(typescript@6.0.3)
- expo-router: 58.0.8(729283f6b97d3a4167f8d9d876f4826b)
+ expo-router: 58.0.8(71370cef79192531445932e2a9c1508c)
expo-updates: 58.0.9(expo-dev-client@58.0.7(expo@58.0.0-preview.7)(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1)))(expo@58.0.0-preview.7)(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0)(supports-color@8.1.1)
metro: 0.87.1(supports-color@8.1.1)
metro-config: 0.87.1(supports-color@8.1.1)
@@ -16276,6 +16290,10 @@ snapshots:
dependencies:
'@types/react': 19.2.18
+ '@types/react-dom@19.2.7(@types/react@19.3.0)':
+ dependencies:
+ '@types/react': 19.3.0
+
'@types/react-reconciler@0.33.0(@types/react@19.3.0)':
dependencies:
'@types/react': 19.3.0
@@ -22344,6 +22362,11 @@ snapshots:
react-native-reanimated: 4.7.0(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0)
use-latest-callback: 0.2.6(react@19.3.0)
+ react-native-ease@0.8.0(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0):
+ dependencies:
+ react: 19.3.0
+ react-native: 0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1)
+
react-native-gesture-handler@3.3.0(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0):
dependencies:
'@types/react-test-renderer': 19.3.0