diff --git a/.js.env.example b/.js.env.example
index 40f7123e053..3598cd6c2b4 100644
--- a/.js.env.example
+++ b/.js.env.example
@@ -150,6 +150,7 @@ export MM_MONEY_DEPOSIT_MIN_ASSET_BALANCE="0.01"
export MM_MONEY_ENABLE_ACTIVITY_DETAILS="true"
export MM_MONEY_ENABLE_ACTIVITY_DETAILS_BLOCKEXPLORER_LINK="true"
export MM_MONEY_FIRST_TIME_DEPOSIT_ANIMATION_ENABLED="true"
+export MM_MONEY_PARALLAX_ANIMATION_ENABLED="true"
# Activates remote feature flag override mode.
# Remote feature flag values won't be updated,
diff --git a/app/animations/next_best_action_module_v1.riv b/app/animations/next_best_action_module_v1.riv
new file mode 100644
index 00000000000..28e3a883ca5
Binary files /dev/null and b/app/animations/next_best_action_module_v1.riv differ
diff --git a/app/component-library/components-temp/StepperCard/StepperCard.tsx b/app/component-library/components-temp/StepperCard/StepperCard.tsx
index e9055c3a88a..e9966fe3e2c 100644
--- a/app/component-library/components-temp/StepperCard/StepperCard.tsx
+++ b/app/component-library/components-temp/StepperCard/StepperCard.tsx
@@ -57,11 +57,13 @@ const StepperCard = ({
{/* Image */}
-
+ {step.media ?? (
+
+ )}
diff --git a/app/component-library/components-temp/StepperCard/StepperCard.types.ts b/app/component-library/components-temp/StepperCard/StepperCard.types.ts
index b06a5f06c5e..c27c3da6668 100644
--- a/app/component-library/components-temp/StepperCard/StepperCard.types.ts
+++ b/app/component-library/components-temp/StepperCard/StepperCard.types.ts
@@ -1,3 +1,4 @@
+import { ReactNode } from 'react';
import { ImageSourcePropType } from 'react-native';
export interface StepperCardCta {
@@ -20,6 +21,11 @@ export interface StepperCardStep {
*/
descriptionTooltipAccessibilityLabel?: string;
image: ImageSourcePropType;
+ /**
+ * Optional custom media rendered in the image slot in place of `image`
+ * (e.g. an animated graphic). Falls back to `image` when not provided.
+ */
+ media?: ReactNode;
primaryCta: StepperCardCta;
secondaryCta?: StepperCardCta;
}
diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx
index 3057e849a2f..e11b6c8d333 100644
--- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx
+++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx
@@ -104,6 +104,14 @@ jest.mock('../../hooks/useMoneyEarnableTokens', () => ({
useMoneyEarnableTokens: () => mockUseMoneyEarnableTokens(),
}));
+// Animated Rive graphic pulls in device sensors; not exercised by these tests.
+jest.mock('../../components/MoneyNextBestActionParallax', () => ({
+ __esModule: true,
+ default: () => null,
+ PARALLAX_ARTBOARD_FUND: 'Parallax Block 1',
+ PARALLAX_ARTBOARD_CARD: 'Parallax Block 2',
+}));
+
jest.mock('../../hooks/useMoneyAccountTransactions', () => ({
useMoneyAccountTransactions: jest.fn(),
}));
diff --git a/app/components/UI/Money/components/MoneyNextBestActionParallax/MoneyNextBestActionParallax.styles.ts b/app/components/UI/Money/components/MoneyNextBestActionParallax/MoneyNextBestActionParallax.styles.ts
new file mode 100644
index 00000000000..cbe6c6b0e25
--- /dev/null
+++ b/app/components/UI/Money/components/MoneyNextBestActionParallax/MoneyNextBestActionParallax.styles.ts
@@ -0,0 +1,10 @@
+import { StyleSheet } from 'react-native';
+
+const styles = StyleSheet.create({
+ media: {
+ width: '100%',
+ height: '100%',
+ },
+});
+
+export default styles;
diff --git a/app/components/UI/Money/components/MoneyNextBestActionParallax/MoneyNextBestActionParallax.test.tsx b/app/components/UI/Money/components/MoneyNextBestActionParallax/MoneyNextBestActionParallax.test.tsx
new file mode 100644
index 00000000000..f37e7659c9d
--- /dev/null
+++ b/app/components/UI/Money/components/MoneyNextBestActionParallax/MoneyNextBestActionParallax.test.tsx
@@ -0,0 +1,220 @@
+import React from 'react';
+import { render, act } from '@testing-library/react-native';
+import MoneyNextBestActionParallax from './MoneyNextBestActionParallax';
+import { MoneyNextBestActionParallaxTestIds } from './MoneyNextBestActionParallax.testIds';
+import { useReduceMotion } from '../../hooks/useReduceMotion';
+import { useDeviceOrientation } from '../../hooks/useDeviceOrientation';
+import fallbackImage from '../../../../../images/money-onboarding-stepper-step-1.png';
+
+const mockSetXValue = jest.fn();
+const mockSetYValue = jest.fn();
+const mockRefCallback = jest.fn();
+const mockRiveInstance = {};
+const mockOnErrorRef: { current?: (error: { message: string }) => void } = {};
+const mockRiveProps: { current?: { artboardName?: string } } = {};
+
+jest.mock('rive-react-native', () => {
+ const ReactActual = jest.requireActual('react');
+ const { View: RNView } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ AutoBind: jest.fn(() => ({})),
+ Fit: { Contain: 'contain' },
+ useRive: () => [mockRefCallback, mockRiveInstance],
+ useRiveNumber: (_instance: unknown, path: string) => [
+ undefined,
+ path === 'xValue' ? mockSetXValue : mockSetYValue,
+ ],
+ default: (props: {
+ testID?: string;
+ artboardName?: string;
+ onError?: (error: { message: string }) => void;
+ }) => {
+ mockOnErrorRef.current = props.onError;
+ mockRiveProps.current = { artboardName: props.artboardName };
+ return ReactActual.createElement(RNView, { testID: props.testID });
+ },
+ };
+});
+
+const mockUseSelector = jest.fn();
+jest.mock('react-redux', () => ({
+ useSelector: (selector: unknown) => mockUseSelector(selector),
+}));
+
+jest.mock('../../hooks/useReduceMotion', () => ({
+ useReduceMotion: jest.fn(),
+}));
+
+jest.mock('../../hooks/useDeviceOrientation', () => ({
+ useDeviceOrientation: jest.fn(),
+}));
+
+const mockUseReduceMotion = useReduceMotion as jest.Mock;
+const mockUseDeviceOrientation = useDeviceOrientation as jest.Mock;
+
+describe('MoneyNextBestActionParallax', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockOnErrorRef.current = undefined;
+ mockRiveProps.current = undefined;
+ mockUseSelector.mockReturnValue(true);
+ mockUseReduceMotion.mockReturnValue(false);
+ });
+
+ it('renders the Rive animation when enabled and reduce motion is off', () => {
+ const { getByTestId, queryByTestId } = render(
+ ,
+ );
+
+ expect(
+ getByTestId(MoneyNextBestActionParallaxTestIds.RIVE),
+ ).toBeOnTheScreen();
+ expect(
+ queryByTestId(MoneyNextBestActionParallaxTestIds.STATIC_IMAGE),
+ ).toBeNull();
+ });
+
+ it('renders the gradient background behind the Rive when animating', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(
+ getByTestId(MoneyNextBestActionParallaxTestIds.BACKGROUND),
+ ).toBeOnTheScreen();
+ });
+
+ it('passes the given artboard name through to Rive', () => {
+ render(
+ ,
+ );
+
+ expect(mockRiveProps.current?.artboardName).toBe('Parallax Block 2');
+ });
+
+ it('does not render the gradient background behind the fallback image', () => {
+ mockUseReduceMotion.mockReturnValue(true);
+
+ const { queryByTestId } = render(
+ ,
+ );
+
+ expect(
+ queryByTestId(MoneyNextBestActionParallaxTestIds.BACKGROUND),
+ ).toBeNull();
+ });
+
+ it('renders the fallback image when the feature flag is disabled', () => {
+ mockUseSelector.mockReturnValue(false);
+
+ const { getByTestId, queryByTestId } = render(
+ ,
+ );
+
+ expect(
+ getByTestId(MoneyNextBestActionParallaxTestIds.STATIC_IMAGE),
+ ).toBeOnTheScreen();
+ expect(queryByTestId(MoneyNextBestActionParallaxTestIds.RIVE)).toBeNull();
+ });
+
+ it('renders the fallback image (with the provided source) when reduce motion is enabled', () => {
+ mockUseReduceMotion.mockReturnValue(true);
+
+ const { getByTestId, queryByTestId } = render(
+ ,
+ );
+
+ expect(
+ getByTestId(MoneyNextBestActionParallaxTestIds.STATIC_IMAGE).props.source,
+ ).toBe(fallbackImage);
+ expect(queryByTestId(MoneyNextBestActionParallaxTestIds.RIVE)).toBeNull();
+ });
+
+ it('enables the device tilt callback when animating', () => {
+ render(
+ ,
+ );
+
+ expect(mockUseDeviceOrientation).toHaveBeenCalledWith(
+ expect.any(Function),
+ {
+ enabled: true,
+ },
+ );
+ });
+
+ it('disables the device tilt callback when not animating', () => {
+ mockUseReduceMotion.mockReturnValue(true);
+
+ render(
+ ,
+ );
+
+ expect(mockUseDeviceOrientation).toHaveBeenCalledWith(
+ expect.any(Function),
+ {
+ enabled: false,
+ },
+ );
+ });
+
+ it('drives the bound Rive number properties from mapped tilt values', () => {
+ render(
+ ,
+ );
+
+ const applyTilt = mockUseDeviceOrientation.mock.calls[0][0] as (
+ x: number,
+ y: number,
+ ) => void;
+
+ act(() => applyTilt(0.5, -0.5));
+
+ expect(mockSetXValue).toHaveBeenCalledWith(75);
+ expect(mockSetYValue).toHaveBeenCalledWith(25);
+ });
+
+ it('falls back to the static image when Rive reports an error', () => {
+ const { getByTestId, queryByTestId } = render(
+ ,
+ );
+
+ act(() => mockOnErrorRef.current?.({ message: 'boom' }));
+
+ expect(
+ getByTestId(MoneyNextBestActionParallaxTestIds.STATIC_IMAGE),
+ ).toBeOnTheScreen();
+ expect(queryByTestId(MoneyNextBestActionParallaxTestIds.RIVE)).toBeNull();
+ });
+});
diff --git a/app/components/UI/Money/components/MoneyNextBestActionParallax/MoneyNextBestActionParallax.testIds.ts b/app/components/UI/Money/components/MoneyNextBestActionParallax/MoneyNextBestActionParallax.testIds.ts
new file mode 100644
index 00000000000..a0c79fa33b0
--- /dev/null
+++ b/app/components/UI/Money/components/MoneyNextBestActionParallax/MoneyNextBestActionParallax.testIds.ts
@@ -0,0 +1,6 @@
+export const MoneyNextBestActionParallaxTestIds = {
+ CONTAINER: 'money-next-best-action-parallax-container',
+ RIVE: 'money-next-best-action-parallax-rive',
+ BACKGROUND: 'money-next-best-action-parallax-background',
+ STATIC_IMAGE: 'money-next-best-action-parallax-static-image',
+} as const;
diff --git a/app/components/UI/Money/components/MoneyNextBestActionParallax/MoneyNextBestActionParallax.tsx b/app/components/UI/Money/components/MoneyNextBestActionParallax/MoneyNextBestActionParallax.tsx
new file mode 100644
index 00000000000..f50f86e2bef
--- /dev/null
+++ b/app/components/UI/Money/components/MoneyNextBestActionParallax/MoneyNextBestActionParallax.tsx
@@ -0,0 +1,132 @@
+import React, { useCallback, useState } from 'react';
+import {
+ Image,
+ ImageSourcePropType,
+ StyleProp,
+ StyleSheet,
+ ViewStyle,
+} from 'react-native';
+import { useSelector } from 'react-redux';
+import { Box } from '@metamask/design-system-react-native';
+import LinearGradient from 'react-native-linear-gradient';
+import Rive, {
+ AutoBind,
+ Fit,
+ RNRiveError,
+ useRive,
+ useRiveNumber,
+} from 'rive-react-native';
+import { createProjectLogger } from '@metamask/utils';
+import { selectMoneyParallaxAnimationEnabledFlag } from '../../selectors/featureFlags';
+import { useReduceMotion } from '../../hooks/useReduceMotion';
+import { useDeviceOrientation } from '../../hooks/useDeviceOrientation';
+import { tiltToParallaxValue } from './parallax';
+import NextBestActionParallaxAnimation from '../../../../../animations/next_best_action_module_v1.riv';
+import styles from './MoneyNextBestActionParallax.styles';
+import { MoneyNextBestActionParallaxTestIds } from './MoneyNextBestActionParallax.testIds';
+
+const log = createProjectLogger('money-parallax');
+
+// Artboard names inside next_best_action_module_v1.riv, one per onboarding step.
+export const PARALLAX_ARTBOARD_FUND = 'Parallax Block 1';
+export const PARALLAX_ARTBOARD_CARD = 'Parallax Block 2';
+
+const RIVE_PROPERTY_X = 'xValue';
+const RIVE_PROPERTY_Y = 'yValue';
+
+// The Rive artboard is transparent — the card's gradient background (sampled
+// from the design) is rendered behind it.
+const PARALLAX_BACKGROUND_COLORS = [
+ 'rgb(24, 1, 101)',
+ 'rgb(40, 43, 142)',
+ 'rgb(57, 93, 191)',
+];
+
+interface MoneyNextBestActionParallaxProps {
+ /** Rive artboard to render (see PARALLAX_ARTBOARD_* constants). */
+ artboardName: string;
+ /** Static image shown when the animation is unavailable or disabled. */
+ fallbackImage: ImageSourcePropType;
+ style?: StyleProp;
+ testID?: string;
+}
+
+const MoneyNextBestActionParallax = ({
+ artboardName,
+ fallbackImage,
+ style,
+ testID,
+}: MoneyNextBestActionParallaxProps) => {
+ const flagEnabled = useSelector(selectMoneyParallaxAnimationEnabledFlag);
+ const reduceMotion = useReduceMotion();
+ const [hasRiveError, setHasRiveError] = useState(false);
+ const [riveRef, riveInstance] = useRive();
+ const [, setXValue] = useRiveNumber(riveInstance, RIVE_PROPERTY_X);
+ const [, setYValue] = useRiveNumber(riveInstance, RIVE_PROPERTY_Y);
+
+ const animate = flagEnabled && !reduceMotion && !hasRiveError;
+
+ const applyTilt = useCallback(
+ (x: number, y: number) => {
+ if (!riveInstance) return;
+ setXValue(tiltToParallaxValue(x));
+ setYValue(tiltToParallaxValue(y));
+ },
+ [riveInstance, setXValue, setYValue],
+ );
+
+ useDeviceOrientation(applyTilt, { enabled: animate });
+
+ const handleError = useCallback((riveError: RNRiveError) => {
+ log(`Rive error: ${riveError.message}`);
+ setHasRiveError(true);
+ }, []);
+
+ let content: React.ReactNode;
+ if (animate) {
+ content = (
+ <>
+
+
+ >
+ );
+ } else {
+ content = (
+
+ );
+ }
+
+ return (
+
+ {content}
+
+ );
+};
+
+export default MoneyNextBestActionParallax;
diff --git a/app/components/UI/Money/components/MoneyNextBestActionParallax/index.ts b/app/components/UI/Money/components/MoneyNextBestActionParallax/index.ts
new file mode 100644
index 00000000000..4a317e3416e
--- /dev/null
+++ b/app/components/UI/Money/components/MoneyNextBestActionParallax/index.ts
@@ -0,0 +1,6 @@
+export {
+ default,
+ PARALLAX_ARTBOARD_FUND,
+ PARALLAX_ARTBOARD_CARD,
+} from './MoneyNextBestActionParallax';
+export { MoneyNextBestActionParallaxTestIds } from './MoneyNextBestActionParallax.testIds';
diff --git a/app/components/UI/Money/components/MoneyNextBestActionParallax/parallax.test.ts b/app/components/UI/Money/components/MoneyNextBestActionParallax/parallax.test.ts
new file mode 100644
index 00000000000..b59a2f5a4c2
--- /dev/null
+++ b/app/components/UI/Money/components/MoneyNextBestActionParallax/parallax.test.ts
@@ -0,0 +1,37 @@
+import {
+ PARALLAX_REST_VALUE,
+ PARALLAX_TILT_AMPLITUDE,
+ tiltToParallaxValue,
+} from './parallax';
+
+describe('tiltToParallaxValue', () => {
+ it('maps a flat device to the resting (centred) value', () => {
+ expect(tiltToParallaxValue(0)).toBe(PARALLAX_REST_VALUE);
+ });
+
+ it('maps full positive tilt to rest + amplitude', () => {
+ expect(tiltToParallaxValue(1)).toBe(
+ PARALLAX_REST_VALUE + PARALLAX_TILT_AMPLITUDE,
+ );
+ });
+
+ it('maps full negative tilt to rest - amplitude', () => {
+ expect(tiltToParallaxValue(-1)).toBe(
+ PARALLAX_REST_VALUE - PARALLAX_TILT_AMPLITUDE,
+ );
+ });
+
+ it('maps a partial tilt linearly around the resting value', () => {
+ expect(tiltToParallaxValue(0.5)).toBe(75);
+ expect(tiltToParallaxValue(-0.5)).toBe(25);
+ });
+
+ it('clamps values beyond the normalized range', () => {
+ expect(tiltToParallaxValue(2)).toBe(
+ PARALLAX_REST_VALUE + PARALLAX_TILT_AMPLITUDE,
+ );
+ expect(tiltToParallaxValue(-2)).toBe(
+ PARALLAX_REST_VALUE - PARALLAX_TILT_AMPLITUDE,
+ );
+ });
+});
diff --git a/app/components/UI/Money/components/MoneyNextBestActionParallax/parallax.ts b/app/components/UI/Money/components/MoneyNextBestActionParallax/parallax.ts
new file mode 100644
index 00000000000..bb4c9b91c42
--- /dev/null
+++ b/app/components/UI/Money/components/MoneyNextBestActionParallax/parallax.ts
@@ -0,0 +1,23 @@
+/**
+ * Rive `xValue` / `yValue` (view model "Main") drive the parallax layers.
+ * The authored resting value is 50 (centred); the `x0`/`x100`/`y0`/`y100`
+ * timelines map 0 → one extreme and 100 → the other.
+ */
+export const PARALLAX_REST_VALUE = 50;
+
+/** Travel from the resting value at full device tilt. 50 → design-native 0..100. */
+export const PARALLAX_TILT_AMPLITUDE = 50;
+
+/**
+ * Maps a normalized device-tilt value (from `useDeviceOrientation`, in the
+ * [-1, 1] range) onto the Rive value, swinging symmetrically around the resting
+ * value.
+ *
+ * A flat device (tilt 0) yields the rest value (centred); full tilt yields
+ * `rest ± PARALLAX_TILT_AMPLITUDE`. The input is clamped so a sensor spike can
+ * never push a layer past its intended travel.
+ */
+export function tiltToParallaxValue(tilt: number): number {
+ const clamped = Math.min(1, Math.max(-1, tilt));
+ return PARALLAX_REST_VALUE + clamped * PARALLAX_TILT_AMPLITUDE;
+}
diff --git a/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.test.tsx b/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.test.tsx
index 973ab3e4554..1634d524a30 100644
--- a/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.test.tsx
+++ b/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.test.tsx
@@ -40,6 +40,11 @@ jest.mock('../../hooks/useMoneyAnalytics', () => ({
useMoneyAnalytics: jest.fn(),
}));
+jest.mock('../MoneyNextBestActionParallax', () => ({
+ __esModule: true,
+ default: () => null,
+}));
+
jest.mock('@metamask/design-system-twrnc-preset', () => {
const tw = (..._args: unknown[]) => ({});
tw.style = jest.fn(() => ({}));
diff --git a/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.tsx b/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.tsx
index 8d6fba783da..cbc884a854e 100644
--- a/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.tsx
+++ b/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.tsx
@@ -10,6 +10,10 @@ import { isPositiveNumber } from '../../utils/number';
import StepperCard, {
type StepperCardStep,
} from '../../../../../component-library/components-temp/StepperCard';
+import MoneyNextBestActionParallax, {
+ PARALLAX_ARTBOARD_CARD,
+ PARALLAX_ARTBOARD_FUND,
+} from '../MoneyNextBestActionParallax';
import { useMoneyAccountDeposit } from '../../hooks/useMoneyAccount';
import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance';
import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics';
@@ -268,8 +272,21 @@ const MoneyOnboardingCard = () => {
onPress: handleStep1CtaPressed,
},
image: moneyOnboardingStepperStep1,
+ media: (
+
+ ),
};
+ const cardStepMedia = (
+
+ );
+
// Case 1: Cardholder, or authenticated with a card not yet linked.
const step2: StepperCardStep = shouldShowLinkCardAction
? {
@@ -301,6 +318,7 @@ const MoneyOnboardingCard = () => {
),
},
image: moneyOnboardingStepperStep2,
+ media: cardStepMedia,
}
: // No MetaMask card yet.
{
@@ -332,6 +350,7 @@ const MoneyOnboardingCard = () => {
),
},
image: moneyOnboardingStepperStep2,
+ media: cardStepMedia,
};
return [step1, step2];
diff --git a/app/components/UI/Money/hooks/useDeviceOrientation.test.ts b/app/components/UI/Money/hooks/useDeviceOrientation.test.ts
new file mode 100644
index 00000000000..5dc4fbc02b5
--- /dev/null
+++ b/app/components/UI/Money/hooks/useDeviceOrientation.test.ts
@@ -0,0 +1,207 @@
+import { renderHook } from '@testing-library/react-native';
+import {
+ accelerationToTilt,
+ useDeviceOrientation,
+} from './useDeviceOrientation';
+
+const mockSubscribe = jest.fn();
+const mockUnsubscribe = jest.fn();
+const mockSetUpdateIntervalForType = jest.fn();
+
+jest.mock('react-native-sensors', () => ({
+ accelerometer: {
+ subscribe: (observer: {
+ next: (value: { x: number; y: number; z: number }) => void;
+ error: () => void;
+ }) => mockSubscribe(observer),
+ },
+ SensorTypes: { accelerometer: 'accelerometer' },
+ setUpdateIntervalForType: (...args: unknown[]) =>
+ mockSetUpdateIntervalForType(...args),
+}));
+
+const mockGetTotalMemorySync = jest.fn();
+jest.mock('react-native-device-info', () => ({
+ getTotalMemorySync: () => mockGetTotalMemorySync(),
+}));
+
+const TWO_GB = 2 * 1024 * 1024 * 1024;
+const FOUR_GB = 4 * 1024 * 1024 * 1024;
+const G = 9.81;
+
+describe('accelerationToTilt', () => {
+ it('is neutral (0) on both axes at the natural 45° holding angle', () => {
+ // pitch 45°: y === hypot(x, z); roll 0: x === 0.
+ const tilt = accelerationToTilt(0, 1, 1);
+ expect(tilt.x).toBeCloseTo(0);
+ expect(tilt.y).toBeCloseTo(0);
+ });
+
+ it('reads a negative pitch when the phone lies flat', () => {
+ const tilt = accelerationToTilt(0, 0, G);
+ expect(tilt.y).toBe(-1); // pitch 0 is well below the 45° neutral
+ expect(tilt.x).toBeCloseTo(0);
+ });
+
+ it('reads a positive roll when tilted right and negative when tilted left', () => {
+ expect(accelerationToTilt(1, 0, 1).x).toBeGreaterThan(0);
+ expect(accelerationToTilt(-1, 0, 1).x).toBeLessThan(0);
+ });
+
+ it('clamps both axes to the [-1, 1] range', () => {
+ const right = accelerationToTilt(G, 0, 0);
+ expect(right.x).toBeLessThanOrEqual(1);
+ expect(right.x).toBeGreaterThanOrEqual(-1);
+ const upright = accelerationToTilt(0, G, 0);
+ expect(upright.y).toBeLessThanOrEqual(1);
+ expect(upright.y).toBeGreaterThanOrEqual(-1);
+ });
+});
+
+describe('useDeviceOrientation', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockSubscribe.mockReturnValue({ unsubscribe: mockUnsubscribe });
+ mockGetTotalMemorySync.mockReturnValue(FOUR_GB);
+ });
+
+ it('subscribes to the accelerometer when enabled', () => {
+ renderHook(() => useDeviceOrientation(jest.fn(), { enabled: true }));
+
+ expect(mockSubscribe).toHaveBeenCalledTimes(1);
+ });
+
+ it('subscribes by default when no options are provided', () => {
+ renderHook(() => useDeviceOrientation(jest.fn()));
+
+ expect(mockSubscribe).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not subscribe when disabled', () => {
+ renderHook(() => useDeviceOrientation(jest.fn(), { enabled: false }));
+
+ expect(mockSubscribe).not.toHaveBeenCalled();
+ });
+
+ it('subscribes when enabled flips from false to true', () => {
+ const { rerender } = renderHook(
+ ({ enabled }) => useDeviceOrientation(jest.fn(), { enabled }),
+ { initialProps: { enabled: false } },
+ );
+
+ expect(mockSubscribe).not.toHaveBeenCalled();
+
+ rerender({ enabled: true });
+
+ expect(mockSubscribe).toHaveBeenCalledTimes(1);
+ });
+
+ it('unsubscribes when enabled flips from true to false', () => {
+ const { rerender } = renderHook(
+ ({ enabled }) => useDeviceOrientation(jest.fn(), { enabled }),
+ { initialProps: { enabled: true } },
+ );
+
+ rerender({ enabled: false });
+
+ expect(mockUnsubscribe).toHaveBeenCalledTimes(1);
+ });
+
+ it('smooths accelerometer samples toward the target tilt', () => {
+ const onOrientation = jest.fn();
+ renderHook(() => useDeviceOrientation(onOrientation, { enabled: true }));
+
+ const observer = mockSubscribe.mock.calls[0][0];
+ // Flat phone: target y is -1. Feed repeatedly so the low-pass converges.
+ for (let i = 0; i < 100; i++) {
+ observer.next({ x: 0, y: 0, z: G });
+ }
+
+ const [x, y] =
+ onOrientation.mock.calls[onOrientation.mock.calls.length - 1];
+ expect(x).toBeCloseTo(0);
+ expect(y).toBeCloseTo(-1);
+ });
+
+ it('emits a value smaller than the target on the first sample (low-pass)', () => {
+ const onOrientation = jest.fn();
+ renderHook(() => useDeviceOrientation(onOrientation, { enabled: true }));
+
+ const observer = mockSubscribe.mock.calls[0][0];
+ observer.next({ x: 0, y: 0, z: G });
+
+ const [, y] = onOrientation.mock.calls[0];
+ expect(y).toBeLessThan(0);
+ expect(y).toBeGreaterThan(-1); // not yet fully converged to -1
+ });
+
+ it('unsubscribes on unmount', () => {
+ const { unmount } = renderHook(() =>
+ useDeviceOrientation(jest.fn(), { enabled: true }),
+ );
+
+ unmount();
+
+ expect(mockUnsubscribe).toHaveBeenCalledTimes(1);
+ });
+
+ it('uses a 60Hz interval on a standard device', () => {
+ mockGetTotalMemorySync.mockReturnValue(FOUR_GB);
+
+ renderHook(() => useDeviceOrientation(jest.fn(), { enabled: true }));
+
+ expect(mockSetUpdateIntervalForType).toHaveBeenCalledWith(
+ 'accelerometer',
+ 1000 / 60,
+ );
+ });
+
+ it('uses a 30Hz interval on a low-end device', () => {
+ mockGetTotalMemorySync.mockReturnValue(TWO_GB);
+
+ renderHook(() => useDeviceOrientation(jest.fn(), { enabled: true }));
+
+ expect(mockSetUpdateIntervalForType).toHaveBeenCalledWith(
+ 'accelerometer',
+ 1000 / 30,
+ );
+ });
+
+ it('does not resubscribe when only the callback identity changes', () => {
+ const { rerender } = renderHook<
+ void,
+ { cb: (x: number, y: number) => void }
+ >(({ cb }) => useDeviceOrientation(cb, { enabled: true }), {
+ initialProps: { cb: jest.fn() },
+ });
+
+ rerender({ cb: jest.fn() });
+
+ expect(mockSubscribe).toHaveBeenCalledTimes(1);
+ });
+
+ it('invokes the latest callback after it changes', () => {
+ const first = jest.fn();
+ const second = jest.fn();
+ const { rerender } = renderHook(
+ ({ cb }) => useDeviceOrientation(cb, { enabled: true }),
+ { initialProps: { cb: first } },
+ );
+
+ rerender({ cb: second });
+
+ const observer = mockSubscribe.mock.calls[0][0];
+ observer.next({ x: 0, y: 0, z: G });
+
+ expect(second).toHaveBeenCalledTimes(1);
+ expect(first).not.toHaveBeenCalled();
+ });
+
+ it('ignores sensor errors without throwing', () => {
+ renderHook(() => useDeviceOrientation(jest.fn(), { enabled: true }));
+
+ const observer = mockSubscribe.mock.calls[0][0];
+
+ expect(() => observer.error()).not.toThrow();
+ });
+});
diff --git a/app/components/UI/Money/hooks/useDeviceOrientation.ts b/app/components/UI/Money/hooks/useDeviceOrientation.ts
new file mode 100644
index 00000000000..6578a69dcd3
--- /dev/null
+++ b/app/components/UI/Money/hooks/useDeviceOrientation.ts
@@ -0,0 +1,94 @@
+import { useEffect, useRef } from 'react';
+import {
+ accelerometer,
+ SensorTypes,
+ setUpdateIntervalForType,
+} from 'react-native-sensors';
+import { getTotalMemorySync } from 'react-native-device-info';
+
+const DEG = Math.PI / 180;
+// Natural holding angle: people hold the phone tilted ~this far back from
+// horizontal. Treated as the neutral (rest) pitch, so holding normally sits at
+// the centre of the parallax rather than an extreme.
+const NEUTRAL_PITCH = 45 * DEG;
+// Rotation away from neutral (per axis) that maps to a full ±1 tilt.
+const PITCH_TRAVEL = 30 * DEG;
+const ROLL_TRAVEL = 30 * DEG;
+// Low-pass factor: higher = snappier but noisier, lower = smoother but laggier.
+const SMOOTHING = 0.2;
+const HZ_LOW_END = 30;
+const HZ_DEFAULT = 60;
+const ONE_GIGABYTE = 1024 * 1024 * 1024;
+
+const clamp = (value: number, min: number, max: number): number =>
+ Math.min(Math.max(value, min), max);
+
+const isLowEndDevice = (): boolean => getTotalMemorySync() <= 2 * ONE_GIGABYTE;
+
+/**
+ * Converts a gravity vector (accelerometer reading) into a normalized,
+ * clamped [-1, 1] tilt per axis, measured as absolute pitch/roll relative to
+ * the natural holding position. Pure so it can be unit-tested directly.
+ */
+export function accelerationToTilt(
+ x: number,
+ y: number,
+ z: number,
+): { x: number; y: number } {
+ const pitch = Math.atan2(y, Math.hypot(x, z));
+ const roll = Math.atan2(x, Math.hypot(y, z));
+ return {
+ x: clamp(roll / ROLL_TRAVEL, -1, 1),
+ y: clamp((pitch - NEUTRAL_PITCH) / PITCH_TRAVEL, -1, 1),
+ };
+}
+
+interface UseDeviceOrientationOptions {
+ enabled?: boolean;
+}
+
+/**
+ * Reports device tilt as a normalized, clamped [-1, 1] value per axis, derived
+ * from the accelerometer's absolute orientation (gravity) with a natural-hold
+ * neutral. Unlike integrating the gyroscope, this is drift-free and returns to
+ * the same neutral for the same physical orientation.
+ *
+ * @param onOrientation - receives (x, y) roll/pitch in the [-1, 1] range.
+ */
+export function useDeviceOrientation(
+ onOrientation: (x: number, y: number) => void,
+ options?: UseDeviceOrientationOptions,
+): void {
+ const enabled = options?.enabled ?? true;
+ const onOrientationRef = useRef(onOrientation);
+ const smoothed = useRef({ x: 0, y: 0 });
+
+ useEffect(() => {
+ onOrientationRef.current = onOrientation;
+ }, [onOrientation]);
+
+ useEffect(() => {
+ // Do no sensor work (including reading device memory) while disabled — e.g.
+ // when the feature flag is off or reduce-motion is on.
+ if (!enabled) return undefined;
+
+ const hz = isLowEndDevice() ? HZ_LOW_END : HZ_DEFAULT;
+ setUpdateIntervalForType(SensorTypes.accelerometer, 1000 / hz);
+
+ smoothed.current = { x: 0, y: 0 };
+
+ const subscription = accelerometer.subscribe({
+ next: ({ x, y, z }) => {
+ const tilt = accelerationToTilt(x, y, z);
+ smoothed.current = {
+ x: smoothed.current.x + SMOOTHING * (tilt.x - smoothed.current.x),
+ y: smoothed.current.y + SMOOTHING * (tilt.y - smoothed.current.y),
+ };
+ onOrientationRef.current(smoothed.current.x, smoothed.current.y);
+ },
+ error: () => undefined,
+ });
+
+ return () => subscription.unsubscribe();
+ }, [enabled]);
+}
diff --git a/app/components/UI/Money/hooks/useReduceMotion.test.ts b/app/components/UI/Money/hooks/useReduceMotion.test.ts
new file mode 100644
index 00000000000..90dd930a6af
--- /dev/null
+++ b/app/components/UI/Money/hooks/useReduceMotion.test.ts
@@ -0,0 +1,70 @@
+import { AccessibilityInfo } from 'react-native';
+import { renderHook, act, waitFor } from '@testing-library/react-native';
+import { useReduceMotion } from './useReduceMotion';
+
+describe('useReduceMotion', () => {
+ const mockRemove = jest.fn();
+ let capturedListener: ((enabled: boolean) => void) | undefined;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ capturedListener = undefined;
+
+ jest
+ .spyOn(AccessibilityInfo, 'isReduceMotionEnabled')
+ .mockResolvedValue(false);
+
+ jest
+ .spyOn(AccessibilityInfo, 'addEventListener')
+ .mockImplementation((_event, listener) => {
+ capturedListener = listener as unknown as (enabled: boolean) => void;
+ return { remove: mockRemove } as never;
+ });
+ });
+
+ it('defaults to true before the initial value resolves', () => {
+ jest
+ .spyOn(AccessibilityInfo, 'isReduceMotionEnabled')
+ .mockReturnValue(new Promise(() => undefined));
+
+ const { result } = renderHook(() => useReduceMotion());
+
+ expect(result.current).toBe(true);
+ });
+
+ it('resolves to false once the check reports reduce motion is off', async () => {
+ const { result } = renderHook(() => useReduceMotion());
+
+ await waitFor(() => expect(result.current).toBe(false));
+ });
+
+ it('resolves to true when reduce motion is enabled', async () => {
+ jest
+ .spyOn(AccessibilityInfo, 'isReduceMotionEnabled')
+ .mockResolvedValue(true);
+
+ const { result } = renderHook(() => useReduceMotion());
+
+ await waitFor(() => expect(result.current).toBe(true));
+ });
+
+ it('updates when the reduceMotionChanged event fires', async () => {
+ const { result } = renderHook(() => useReduceMotion());
+
+ await waitFor(() => expect(result.current).toBe(false));
+
+ act(() => {
+ capturedListener?.(true);
+ });
+
+ expect(result.current).toBe(true);
+ });
+
+ it('removes the listener on unmount', () => {
+ const { unmount } = renderHook(() => useReduceMotion());
+
+ unmount();
+
+ expect(mockRemove).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/app/components/UI/Money/hooks/useReduceMotion.ts b/app/components/UI/Money/hooks/useReduceMotion.ts
new file mode 100644
index 00000000000..e40e3d39116
--- /dev/null
+++ b/app/components/UI/Money/hooks/useReduceMotion.ts
@@ -0,0 +1,28 @@
+import { useEffect, useState } from 'react';
+import { AccessibilityInfo } from 'react-native';
+
+export function useReduceMotion(): boolean {
+ // Default to true so callers don't animate before the async check resolves —
+ // otherwise a user with reduce-motion enabled briefly sees the animation.
+ const [reduceMotion, setReduceMotion] = useState(true);
+
+ useEffect(() => {
+ let mounted = true;
+
+ AccessibilityInfo.isReduceMotionEnabled().then((enabled) => {
+ if (mounted) setReduceMotion(enabled);
+ });
+
+ const subscription = AccessibilityInfo.addEventListener(
+ 'reduceMotionChanged',
+ setReduceMotion,
+ );
+
+ return () => {
+ mounted = false;
+ subscription.remove();
+ };
+ }, []);
+
+ return reduceMotion;
+}
diff --git a/app/components/UI/Money/selectors/featureFlags.test.ts b/app/components/UI/Money/selectors/featureFlags.test.ts
index 48b453968a7..1897ff29a4f 100644
--- a/app/components/UI/Money/selectors/featureFlags.test.ts
+++ b/app/components/UI/Money/selectors/featureFlags.test.ts
@@ -12,6 +12,7 @@ import {
selectMoneyCardActivityCashbackMultisendContracts,
selectMoneyNoFeeDepositTokens,
selectMoneyFirstTimeDepositAnimationEnabledFlag,
+ selectMoneyParallaxAnimationEnabledFlag,
selectMoneyVaultApyRemoteConfig,
} from './featureFlags';
import { DEFAULT_MONEY_CARD_ACTIVITY_CASHBACK_MULTISEND_CONTRACTS } from '../utils/accountsApi';
@@ -554,6 +555,91 @@ describe('selectMoneyFirstTimeDepositAnimationEnabledFlag', () => {
});
});
+describe('selectMoneyParallaxAnimationEnabledFlag', () => {
+ const originalEnv = process.env;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ process.env = { ...originalEnv };
+ });
+
+ afterEach(() => {
+ process.env = originalEnv;
+ });
+
+ it('returns true when remote flag is enabled and version requirement is met', () => {
+ mockedValidate.mockReturnValue(true);
+
+ const state = createState({
+ earnMoneyParallaxAnimationEnabled: {
+ enabled: true,
+ minimumVersion: '1.0.0',
+ },
+ });
+
+ const result = selectMoneyParallaxAnimationEnabledFlag(state as never);
+
+ expect(result).toBe(true);
+ });
+
+ it('returns false when remote flag is disabled', () => {
+ mockedValidate.mockReturnValue(false);
+
+ const state = createState({
+ earnMoneyParallaxAnimationEnabled: {
+ enabled: false,
+ minimumVersion: '1.0.0',
+ },
+ });
+
+ const result = selectMoneyParallaxAnimationEnabledFlag(state as never);
+
+ expect(result).toBe(false);
+ });
+
+ it('defaults to false when remote flag returns undefined and env is unset', () => {
+ mockedValidate.mockReturnValue(undefined);
+ delete process.env.MM_MONEY_PARALLAX_ANIMATION_ENABLED;
+
+ const state = createState({
+ _unique: 'parallax-default-off',
+ });
+
+ const result = selectMoneyParallaxAnimationEnabledFlag(state as never);
+
+ expect(result).toBe(false);
+ });
+
+ it('returns true when env var is set to true and remote is undefined', () => {
+ mockedValidate.mockReturnValue(undefined);
+ process.env.MM_MONEY_PARALLAX_ANIMATION_ENABLED = 'true';
+
+ const state = createState({
+ _unique: 'parallax-env-true',
+ });
+
+ const result = selectMoneyParallaxAnimationEnabledFlag(state as never);
+
+ expect(result).toBe(true);
+ });
+
+ it('remote flag takes precedence over env var', () => {
+ mockedValidate.mockReturnValue(false);
+ process.env.MM_MONEY_PARALLAX_ANIMATION_ENABLED = 'true';
+
+ const state = createState({
+ earnMoneyParallaxAnimationEnabled: {
+ enabled: false,
+ minimumVersion: '1.0.0',
+ },
+ });
+
+ const result = selectMoneyParallaxAnimationEnabledFlag(state as never);
+
+ expect(result).toBe(false);
+ });
+});
+
describe('selectMoneyVaultApyRemoteConfig', () => {
beforeEach(() => {
jest.clearAllMocks();
diff --git a/app/components/UI/Money/selectors/featureFlags.ts b/app/components/UI/Money/selectors/featureFlags.ts
index 0d910b0b156..f3daa56db91 100644
--- a/app/components/UI/Money/selectors/featureFlags.ts
+++ b/app/components/UI/Money/selectors/featureFlags.ts
@@ -33,6 +33,23 @@ export const selectMoneyEnableActivityDetailsFlag = createSelector(
},
);
+/**
+ * Selects whether the tilt-driven parallax animation is shown on the Money
+ * onboarding "Next Best Action" card. Defaults to off (opt-in) so the feature
+ * stays disabled unless the remote flag or MM_MONEY_PARALLAX_ANIMATION_ENABLED
+ * turns it on; the static image is used otherwise, when reduce-motion is
+ * enabled, or when Rive fails to load.
+ */
+export const selectMoneyParallaxAnimationEnabledFlag = createSelector(
+ selectRemoteFeatureFlags,
+ (remoteFeatureFlags) => {
+ const remoteFlag =
+ remoteFeatureFlags?.earnMoneyParallaxAnimationEnabled as unknown as VersionGatedFeatureFlag;
+ const local = process.env.MM_MONEY_PARALLAX_ANIMATION_ENABLED === 'true';
+ return validatedVersionGatedFeatureFlag(remoteFlag) ?? local;
+ },
+);
+
/**
* Selects whether the block explorer link is shown in Money activity detail
* views. When off, the "View on block explorer" button is hidden.
diff --git a/tests/feature-flags/feature-flag-registry.ts b/tests/feature-flags/feature-flag-registry.ts
index e233b494dcb..ba1d6aa131a 100644
--- a/tests/feature-flags/feature-flag-registry.ts
+++ b/tests/feature-flags/feature-flag-registry.ts
@@ -3334,6 +3334,13 @@ export const FEATURE_FLAG_REGISTRY: Record = {
},
status: FeatureFlagStatus.Active,
},
+ earnMoneyParallaxAnimationEnabled: {
+ name: 'earnMoneyParallaxAnimationEnabled',
+ type: FeatureFlagType.Remote,
+ inProd: false,
+ productionDefault: false,
+ status: FeatureFlagStatus.Active,
+ },
earnMoneyVaultApyControl: {
name: 'earnMoneyVaultApyControl',