diff --git a/packages/mobile/src/stepper/DefaultStepperHeaderHorizontal.tsx b/packages/mobile/src/stepper/DefaultStepperHeaderHorizontal.tsx
index 6afa2475f..b40840d8e 100644
--- a/packages/mobile/src/stepper/DefaultStepperHeaderHorizontal.tsx
+++ b/packages/mobile/src/stepper/DefaultStepperHeaderHorizontal.tsx
@@ -1,17 +1,20 @@
-import { memo, useEffect, useMemo } from 'react';
-import { animated, useSpring } from '@react-spring/native';
+import { memo, useEffect, useMemo, useRef, useState } from 'react';
+import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
+import { durations } from '@coinbase/cds-common/motion/tokens';
import { HStack } from '../layout/HStack';
+import { mobileCurves } from '../motion/convertMotionConfig';
import { Text } from '../typography/Text';
import type { StepperHeaderComponent } from './Stepper';
-const AnimatedHStack = animated(HStack);
+const AnimatedHStack = Animated.createAnimatedComponent(HStack);
export const DefaultStepperHeaderHorizontal: StepperHeaderComponent = memo(
function DefaultStepperHeaderHorizontal({
activeStep,
complete,
+ disableAnimateOnMount,
flatStepIds,
style,
paddingBottom = 1.5,
@@ -20,27 +23,41 @@ export const DefaultStepperHeaderHorizontal: StepperHeaderComponent = memo(
fontFamily = font,
...props
}) {
- const [spring, springApi] = useSpring(
- {
- from: { opacity: 0 },
- to: { opacity: 1 },
- reset: true,
- },
- [],
- );
+ const opacity = useSharedValue(disableAnimateOnMount ? 1 : 0);
+ const disableAnimateOnMountRef = useRef(disableAnimateOnMount);
+ const isInitialRender = useRef(true);
+
+ const [displayedStep, setDisplayedStep] = useState(activeStep);
+ const [displayedComplete, setDisplayedComplete] = useState(complete);
- // TO DO: resetting the spring doesn't work like it does in react-spring on web
- // need to look into this deeper and understand why there is a difference in behavior
useEffect(() => {
- springApi.start({
- from: { opacity: 0 },
- to: { opacity: 1 },
- reset: true,
- });
- }, [springApi, activeStep]);
+ if (isInitialRender.current) {
+ isInitialRender.current = false;
+ setDisplayedStep(activeStep);
+ setDisplayedComplete(complete);
+ if (disableAnimateOnMountRef.current) return;
+ opacity.value = withTiming(1, { duration: durations.fast1, easing: mobileCurves.linear });
+ return;
+ }
+
+ // Fade out with old text, then swap text and fade in
+ opacity.value = withTiming(0, { duration: durations.fast1, easing: mobileCurves.linear });
+
+ const timeout = setTimeout(() => {
+ setDisplayedStep(activeStep);
+ setDisplayedComplete(complete);
+ opacity.value = withTiming(1, { duration: durations.fast1, easing: mobileCurves.linear });
+ }, durations.fast1 + durations.fast1);
+
+ return () => clearTimeout(timeout);
+ }, [activeStep, complete, opacity]);
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ opacity: opacity.value,
+ }));
- const styles = useMemo(() => [style, spring] as any, [style, spring]);
- const flatStepIndex = activeStep ? flatStepIds.indexOf(activeStep.id) : -1;
+ const styles = useMemo(() => [style, animatedStyle], [style, animatedStyle]);
+ const flatStepIndex = displayedStep ? flatStepIds.indexOf(displayedStep.id) : -1;
const emptyText = ' '; // Simple space for React Native
return (
@@ -52,7 +69,7 @@ export const DefaultStepperHeaderHorizontal: StepperHeaderComponent = memo(
{...props}
>
- {!activeStep || complete ? (
+ {!displayedStep || displayedComplete ? (
emptyText
) : (
@@ -65,12 +82,12 @@ export const DefaultStepperHeaderHorizontal: StepperHeaderComponent = memo(
>
{flatStepIndex + 1}/{flatStepIds.length}
- {activeStep.label && typeof activeStep.label === 'string' ? (
+ {displayedStep.label && typeof displayedStep.label === 'string' ? (
- {activeStep.label}
+ {displayedStep.label}
) : (
- activeStep.label
+ displayedStep.label
)}
)}
diff --git a/packages/mobile/src/stepper/DefaultStepperProgressHorizontal.tsx b/packages/mobile/src/stepper/DefaultStepperProgressHorizontal.tsx
index 7110050e1..7d07a6721 100644
--- a/packages/mobile/src/stepper/DefaultStepperProgressHorizontal.tsx
+++ b/packages/mobile/src/stepper/DefaultStepperProgressHorizontal.tsx
@@ -1,11 +1,12 @@
-import { memo } from 'react';
-import { animated, to } from '@react-spring/native';
+import { memo, useCallback, useEffect } from 'react';
+import type { LayoutChangeEvent } from 'react-native';
+import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
import { Box } from '../layout/Box';
import type { StepperProgressComponent } from './Stepper';
-const AnimatedBox = animated(Box);
+const AnimatedBox = Animated.createAnimatedComponent(Box);
export const DefaultStepperProgressHorizontal: StepperProgressComponent = memo(
function DefaultStepperProgressHorizontal({
@@ -19,7 +20,7 @@ export const DefaultStepperProgressHorizontal: StepperProgressComponent = memo(
progress,
complete,
isDescendentActive,
- progressSpringConfig,
+ progressTimingConfig,
animate,
disableAnimateOnMount,
style,
@@ -33,6 +34,24 @@ export const DefaultStepperProgressHorizontal: StepperProgressComponent = memo(
height = 4,
...props
}) {
+ const containerWidth = useSharedValue(0);
+ const animatedProgress = useSharedValue(progress);
+
+ const handleLayout = useCallback(
+ (event: LayoutChangeEvent) => {
+ containerWidth.value = event.nativeEvent.layout.width;
+ },
+ [containerWidth],
+ );
+
+ useEffect(() => {
+ animatedProgress.value = withTiming(progress, progressTimingConfig);
+ }, [progress, progressTimingConfig, animatedProgress]);
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ width: animatedProgress.value * containerWidth.value,
+ }));
+
return (
@@ -57,7 +77,7 @@ export const DefaultStepperProgressHorizontal: StepperProgressComponent = memo(
}
borderRadius={borderRadius}
height="100%"
- width={to([progress], (width) => `${width * 100}%`)}
+ style={animatedStyle}
/>
);
diff --git a/packages/mobile/src/stepper/DefaultStepperProgressVertical.tsx b/packages/mobile/src/stepper/DefaultStepperProgressVertical.tsx
index d582c8170..1530a9687 100644
--- a/packages/mobile/src/stepper/DefaultStepperProgressVertical.tsx
+++ b/packages/mobile/src/stepper/DefaultStepperProgressVertical.tsx
@@ -1,13 +1,13 @@
-import { memo, useCallback, useMemo } from 'react';
-import { useHasMounted } from '@coinbase/cds-common/hooks/useHasMounted';
+import { memo, useCallback, useEffect, useMemo } from 'react';
+import type { LayoutChangeEvent } from 'react-native';
+import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
import { flattenSteps } from '@coinbase/cds-common/stepper/utils';
-import { animated, to, useSpring } from '@react-spring/native';
import { Box } from '../layout/Box';
import type { StepperProgressComponent, StepperValue } from './Stepper';
-const AnimatedBox = animated(Box);
+const AnimatedBox = Animated.createAnimatedComponent(Box);
export const DefaultStepperProgressVertical: StepperProgressComponent = memo(
function DefaultStepperProgressVertical({
@@ -23,9 +23,7 @@ export const DefaultStepperProgressVertical: StepperProgressComponent = memo(
isDescendentActive,
style,
activeStepLabelElement,
- progressSpringConfig,
- animate = true,
- disableAnimateOnMount,
+ progressTimingConfig,
background = 'bgLine',
defaultFill = 'bgLinePrimarySubtle',
activeFill = 'bgLinePrimarySubtle',
@@ -36,7 +34,6 @@ export const DefaultStepperProgressVertical: StepperProgressComponent = memo(
width = 2,
...props
}) {
- const hasMounted = useHasMounted();
const isLastStep = flatStepIds[flatStepIds.length - 1] === step.id;
// Count the total number of sub-steps in the current step's tree
@@ -56,35 +53,45 @@ export const DefaultStepperProgressVertical: StepperProgressComponent = memo(
[],
);
+ // Fractional fill for steps with sub-steps. For all other cases, return 1
+ // and let the cascade's `progress` prop control whether the bar is filled.
const progressHeight = useMemo(() => {
const totalSubSteps = countAllSubSteps(step.subSteps ?? []);
- if (complete) return 1;
- if (active && totalSubSteps === 0) return 1;
- if (active && !isDescendentActive) return 0;
- if (isDescendentActive) {
+ if (active && totalSubSteps > 0 && !isDescendentActive) return 0;
+ if (isDescendentActive && totalSubSteps > 0) {
const activePosition = findSubStepPosition(step.subSteps ?? [], activeStepId);
return activePosition / totalSubSteps;
}
- if (visited) return 1;
- return 0;
+ return 1;
}, [
countAllSubSteps,
step.subSteps,
- complete,
active,
isDescendentActive,
- visited,
findSubStepPosition,
activeStepId,
]);
- const fillHeightSpring = useSpring({
- height: progressHeight,
- immediate: !animate || (disableAnimateOnMount && !hasMounted),
- config: progressSpringConfig,
- });
+ const containerHeight = useSharedValue(0);
+ const targetHeight = progress * progressHeight;
+ const animatedHeight = useSharedValue(targetHeight);
+
+ const handleLayout = useCallback(
+ (event: LayoutChangeEvent) => {
+ containerHeight.value = event.nativeEvent.layout.height;
+ },
+ [containerHeight],
+ );
+
+ useEffect(() => {
+ animatedHeight.value = withTiming(targetHeight, progressTimingConfig);
+ }, [targetHeight, progressTimingConfig, animatedHeight]);
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ height: animatedHeight.value * containerHeight.value,
+ }));
if (depth > 0 || isLastStep) return null;
@@ -93,6 +100,7 @@ export const DefaultStepperProgressVertical: StepperProgressComponent = memo(
background={background}
flexGrow={1}
minHeight={minHeight}
+ onLayout={handleLayout}
position="relative"
style={style}
width={width}
@@ -110,8 +118,8 @@ export const DefaultStepperProgressVertical: StepperProgressComponent = memo(
? visitedFill
: defaultFill
}
- height={to([progress, fillHeightSpring.height], (p, h) => `${p * h * 100}%`)}
position="absolute"
+ style={animatedStyle}
width="100%"
/>
diff --git a/packages/mobile/src/stepper/DefaultStepperStepHorizontal.tsx b/packages/mobile/src/stepper/DefaultStepperStepHorizontal.tsx
index 8e94ff179..fdf3221c8 100644
--- a/packages/mobile/src/stepper/DefaultStepperStepHorizontal.tsx
+++ b/packages/mobile/src/stepper/DefaultStepperStepHorizontal.tsx
@@ -24,7 +24,7 @@ export const DefaultStepperStepHorizontal: StepperStepComponent = memo(
styles,
activeStepLabelElement,
setActiveStepLabelElement,
- progressSpringConfig,
+ progressTimingConfig,
animate,
disableAnimateOnMount,
StepperStepComponent = DefaultStepperStepHorizontal,
@@ -75,7 +75,7 @@ export const DefaultStepperStepHorizontal: StepperStepComponent = memo(
isDescendentActive={isDescendentActive}
parentStep={parentStep}
progress={progress}
- progressSpringConfig={progressSpringConfig}
+ progressTimingConfig={progressTimingConfig}
step={step}
style={styles?.progress}
visited={visited}
@@ -140,7 +140,7 @@ export const DefaultStepperStepHorizontal: StepperStepComponent = memo(
isDescendentActive={isDescendentActive}
parentStep={step}
progress={progress}
- progressSpringConfig={progressSpringConfig}
+ progressTimingConfig={progressTimingConfig}
setActiveStepLabelElement={setActiveStepLabelElement}
step={subStep}
styles={styles}
diff --git a/packages/mobile/src/stepper/DefaultStepperStepVertical.tsx b/packages/mobile/src/stepper/DefaultStepperStepVertical.tsx
index ae544fc18..77aad211e 100644
--- a/packages/mobile/src/stepper/DefaultStepperStepVertical.tsx
+++ b/packages/mobile/src/stepper/DefaultStepperStepVertical.tsx
@@ -27,7 +27,7 @@ export const DefaultStepperStepVertical: StepperStepComponent = memo(
styles,
activeStepLabelElement,
setActiveStepLabelElement,
- progressSpringConfig,
+ progressTimingConfig,
animate,
disableAnimateOnMount,
StepperStepComponent = DefaultStepperStepVertical,
@@ -80,7 +80,7 @@ export const DefaultStepperStepVertical: StepperStepComponent = memo(
isDescendentActive={isDescendentActive}
parentStep={parentStep}
progress={progress}
- progressSpringConfig={progressSpringConfig}
+ progressTimingConfig={progressTimingConfig}
step={step}
style={styles?.progress}
visited={visited}
@@ -146,7 +146,7 @@ export const DefaultStepperStepVertical: StepperStepComponent = memo(
isDescendentActive={isDescendentActive}
parentStep={step}
progress={progress}
- progressSpringConfig={progressSpringConfig}
+ progressTimingConfig={progressTimingConfig}
setActiveStepLabelElement={setActiveStepLabelElement}
step={subStep}
styles={styles}
diff --git a/packages/mobile/src/stepper/Stepper.tsx b/packages/mobile/src/stepper/Stepper.tsx
index e780f1fdf..701769139 100644
--- a/packages/mobile/src/stepper/Stepper.tsx
+++ b/packages/mobile/src/stepper/Stepper.tsx
@@ -1,19 +1,15 @@
-import React, { forwardRef, memo, useEffect, useMemo, useState } from 'react';
+import React, { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { StyleProp, View, ViewStyle } from 'react-native';
+import type { WithTimingConfig } from 'react-native-reanimated';
import type { ThemeVars } from '@coinbase/cds-common/core/theme';
-import { useHasMounted } from '@coinbase/cds-common/hooks/useHasMounted';
-import { usePreviousValue } from '@coinbase/cds-common/hooks/usePreviousValue';
+import { durations } from '@coinbase/cds-common/motion/tokens';
import { containsStep, flattenSteps, isStepVisited } from '@coinbase/cds-common/stepper/utils';
import type { IconName } from '@coinbase/cds-common/types';
-import {
- type SpringConfig,
- type SpringValue as SpringValueType,
- useSprings,
-} from '@react-spring/native';
import type { IconProps } from '../icons/Icon';
import { Box, type BoxBaseProps, type BoxProps } from '../layout/Box';
import { VStack } from '../layout/VStack';
+import { mobileCurves } from '../motion/convertMotionConfig';
import { DefaultStepperHeaderHorizontal } from './DefaultStepperHeaderHorizontal';
import { DefaultStepperIconVertical } from './DefaultStepperIconVertical';
@@ -73,13 +69,13 @@ export type StepperStepProps = Record &
BoxProps & {
/**
- * An animated SpringValue between 0 and 1.
- * You can use this to animate your own custom Progress subcomponent.
+ * A value between 0 and 1 representing the step's progress.
+ * Progress bar subcomponents animate to this value internally.
*/
- progress: SpringValueType;
+ progress: number;
activeStepLabelElement: View | null;
setActiveStepLabelElement: (element: View) => void;
- progressSpringConfig?: SpringConfig;
+ progressTimingConfig?: WithTimingConfig;
animate?: boolean;
disableAnimateOnMount?: boolean;
completedStepAccessibilityLabel?: string;
@@ -110,6 +106,7 @@ export type StepperHeaderProps = Record
activeStep: StepperValue | null;
flatStepIds: string[];
complete?: boolean;
+ disableAnimateOnMount?: boolean;
style?: StyleProp;
};
@@ -129,9 +126,9 @@ export type StepperProgressProps<
Metadata extends Record = Record,
> = StepperSubcomponentProps &
BoxProps & {
- progress: SpringValueType;
+ progress: number;
activeStepLabelElement: View | null;
- progressSpringConfig?: SpringConfig;
+ progressTimingConfig?: WithTimingConfig;
animate?: boolean;
disableAnimateOnMount?: boolean;
defaultFill?: ThemeVars.Color;
@@ -215,9 +212,9 @@ export type StepperBaseProps = Record | null;
/** An optional component to render in place of the default Header subcomponent. Set to null to render nothing in this slot. */
StepperHeaderComponent?: StepperHeaderComponent | null;
- /** The spring config to use for the progress spring. */
- progressSpringConfig?: SpringConfig;
- /** Whether to animate the progress spring.
+ /** The timing config to use for the progress animation. */
+ progressTimingConfig?: WithTimingConfig;
+ /** Whether to animate the progress bar.
* @default true
*/
animate?: boolean;
@@ -248,8 +245,12 @@ export type StepperProps = Record = Record>(
props: StepperProps & { ref?: React.Ref },
@@ -287,14 +288,13 @@ const StepperBase = memo(
StepperHeaderComponent = direction === 'vertical'
? null
: (DefaultStepperHeaderHorizontal as StepperHeaderComponent),
- progressSpringConfig = defaultProgressSpringConfig,
+ progressTimingConfig = defaultProgressTimingConfig,
animate = true,
disableAnimateOnMount,
...props
}: StepperProps,
ref: React.Ref,
) => {
- const hasMounted = useHasMounted();
const flatStepIds = useMemo(() => flattenSteps(steps).map((step) => step.id), [steps]);
// Derive activeStep from activeStepId
@@ -339,90 +339,57 @@ const StepperBase = memo(
: -1;
}, [activeStepId, steps]);
- const previousComplete = usePreviousValue(complete) ?? false;
- const previousActiveStepIndex = usePreviousValue(activeStepIndex) ?? -1;
+ // The effective cascade target: when complete, fill all steps up to the last one.
+ // Otherwise, fill up to activeStepIndex.
+ const cascadeTarget = complete ? steps.length - 1 : activeStepIndex;
- const [progressSprings, progressSpringsApi] = useSprings(steps.length, (index) => ({
- progress: complete ? 1 : 0,
- config: progressSpringConfig,
- immediate: !animate || (disableAnimateOnMount && !hasMounted),
- }));
+ // Cascade animation state: advances one step at a time toward cascadeTarget.
+ // When disableAnimateOnMount is false (default), start unfilled (-1) so the
+ // cascade animates bars one-at-a-time up to the target on mount.
+ const [filledStepIndex, setFilledStepIndex] = useState(() =>
+ disableAnimateOnMount ? cascadeTarget : -1,
+ );
+ const targetStepIndexRef = useRef(cascadeTarget);
useEffect(() => {
- // update the previous values for next render
- let stepsToAnimate: number[] = [];
- let isAnimatingForward = false;
-
- // Case when going from not-complete to complete
- if (Boolean(complete) !== previousComplete) {
- if (complete) {
- // Going to complete: animate from activeStepIndex+1 to end
- stepsToAnimate = Array.from(
- { length: steps.length - activeStepIndex - 1 },
- (_, i) => activeStepIndex + 1 + i,
- );
- isAnimatingForward = true;
- } else {
- // Going from complete: animate from end down to activeStepIndex+1
- stepsToAnimate = Array.from(
- { length: steps.length - activeStepIndex - 1 },
- (_, i) => steps.length - 1 - i,
- );
- isAnimatingForward = false;
- }
- }
+ targetStepIndexRef.current = cascadeTarget;
- // Case for normal step navigation (e.g. step 1 => step 2)
- else if (activeStepIndex !== previousActiveStepIndex) {
- if (activeStepIndex > previousActiveStepIndex) {
- // Forward: animate from previousActiveStepIndex+1 to activeStepIndex
- stepsToAnimate = Array.from(
- { length: activeStepIndex - previousActiveStepIndex },
- (_, i) => previousActiveStepIndex + 1 + i,
- );
- isAnimatingForward = true;
- } else {
- // Backward: animate from previousActiveStepIndex down to activeStepIndex+1
- stepsToAnimate = Array.from(
- { length: previousActiveStepIndex - activeStepIndex },
- (_, i) => previousActiveStepIndex - i,
- );
- isAnimatingForward = false;
- }
+ if (!animate) {
+ setFilledStepIndex(cascadeTarget);
+ return;
}
- const animateNextStep = () => {
- if (stepsToAnimate.length === 0) return;
- const stepIndex = stepsToAnimate.shift();
- if (stepIndex === undefined) return;
-
- progressSpringsApi.start((index) =>
- index === stepIndex
- ? {
- progress: isAnimatingForward ? 1 : 0,
- config: progressSpringConfig,
- onRest: animateNextStep,
- immediate: !animate || (disableAnimateOnMount && !hasMounted),
- }
- : {},
- );
- };
-
- // start the animation loop for relevant springs (stepsToAnimate)
- animateNextStep();
- }, [
- progressSpringsApi,
- complete,
- steps.length,
- steps,
- activeStepIndex,
- previousActiveStepIndex,
- previousComplete,
- progressSpringConfig,
- animate,
- disableAnimateOnMount,
- hasMounted,
- ]);
+ // Advance one step immediately to kick off the cascade
+ setFilledStepIndex((prev) => {
+ if (prev === cascadeTarget) return prev;
+ return prev < cascadeTarget ? prev + 1 : prev - 1;
+ });
+
+ // Continue advancing on a fixed interval for fluid, overlapping springs
+ const interval = setInterval(() => {
+ setFilledStepIndex((prev) => {
+ const target = targetStepIndexRef.current;
+ if (prev === target) return prev;
+ return prev < target ? prev + 1 : prev - 1;
+ });
+ }, cascadeStaggerMs);
+
+ return () => clearInterval(interval);
+ }, [cascadeTarget, animate]);
+
+ // Compute progress for each step: 1 if filled, 0 if not
+ const getStepProgress = useCallback(
+ (index: number) => {
+ if (!animate) {
+ if (complete) return 1;
+ if (activeStepIndex < 0) return 0;
+ return index <= activeStepIndex ? 1 : 0;
+ }
+ if (filledStepIndex < 0) return 0;
+ return index <= filledStepIndex ? 1 : 0;
+ },
+ [complete, animate, activeStepIndex, filledStepIndex],
+ );
return (
@@ -446,42 +414,43 @@ const StepperBase = memo(
? containsStep({ step, targetStepId: activeStepId })
: false;
const RenderedStepComponent = step.Component ?? StepperStepComponent;
+
+ if (!RenderedStepComponent) return null;
+
return (
- RenderedStepComponent && (
-
- )
+
);
})}
diff --git a/packages/mobile/src/stepper/__stories__/StepperHorizontal.stories.tsx b/packages/mobile/src/stepper/__stories__/StepperHorizontal.stories.tsx
index 835f437ac..1637559b4 100644
--- a/packages/mobile/src/stepper/__stories__/StepperHorizontal.stories.tsx
+++ b/packages/mobile/src/stepper/__stories__/StepperHorizontal.stories.tsx
@@ -3,6 +3,7 @@ import { loremIpsum } from '@coinbase/cds-common/internal/data/loremIpsum';
import { useStepper } from '@coinbase/cds-common/stepper/useStepper';
import { Button } from '../../buttons';
+import { Switch } from '../../controls/Switch';
import { Example, ExampleScreen } from '../../examples/ExampleScreen';
import { Icon } from '../../icons/Icon';
import { HStack, VStack } from '../../layout';
@@ -216,6 +217,29 @@ const NoActiveStep = () => {
return ;
};
+// ------------------------------------------------------------
+// Disable Animate on Mount
+// ------------------------------------------------------------
+const DisableAnimateOnMount = () => {
+ const [disableAnimateOnMount, setDisableAnimateOnMount] = useState(false);
+
+ return (
+
+ setDisableAnimateOnMount((prev) => !prev)}
+ >
+ disableAnimateOnMount
+
+
+
+ );
+};
+
// ------------------------------------------------------------
// Custom Progress Component
// ------------------------------------------------------------
@@ -264,6 +288,10 @@ const StepperHorizontalScreen = () => {
+
+
+
+
diff --git a/packages/mobile/src/stepper/__stories__/StepperVertical.stories.tsx b/packages/mobile/src/stepper/__stories__/StepperVertical.stories.tsx
index caf2bb517..aa067c3d6 100644
--- a/packages/mobile/src/stepper/__stories__/StepperVertical.stories.tsx
+++ b/packages/mobile/src/stepper/__stories__/StepperVertical.stories.tsx
@@ -8,6 +8,7 @@ import {
import { Button } from '../../buttons';
import { ListCell } from '../../cells';
import { Collapsible } from '../../collapsible';
+import { Switch } from '../../controls/Switch';
import { Example, ExampleScreen } from '../../examples/ExampleScreen';
import { Icon } from '../../icons/Icon';
import { Box, HStack, VStack } from '../../layout';
@@ -247,6 +248,36 @@ const InitialActiveStep = () => {
return ;
};
+// ------------------------------------------------------------
+// Disable Animate on Mount
+// ------------------------------------------------------------
+const disableAnimateOnMountSteps: StepperValue[] = [
+ { id: 'first-step', label: 'First step' },
+ { id: 'second-step', label: 'Second step' },
+ { id: 'third-step', label: 'Third step' },
+ { id: 'final-step', label: 'Final step' },
+];
+
+const DisableAnimateOnMount = () => {
+ const [disableAnimateOnMount, setDisableAnimateOnMount] = useState(false);
+
+ return (
+
+ setDisableAnimateOnMount((prev) => !prev)}
+ >
+ disableAnimateOnMount
+
+
+
+ );
+};
+
// ------------------------------------------------------------
// Nested Steps
// ------------------------------------------------------------
@@ -733,6 +764,10 @@ const StepperVerticalScreen = () => {
+
+
+
+
diff --git a/packages/mobile/src/tour/DefaultTourMask.tsx b/packages/mobile/src/tour/DefaultTourMask.tsx
index 4e2145243..e343c0775 100644
--- a/packages/mobile/src/tour/DefaultTourMask.tsx
+++ b/packages/mobile/src/tour/DefaultTourMask.tsx
@@ -1,7 +1,9 @@
import React, { memo, useEffect, useMemo, useState } from 'react';
+import { Platform } from 'react-native';
import { Defs, Mask, Rect as NativeRect, Svg } from 'react-native-svg';
import { defaultRect, type Rect } from '@coinbase/cds-common/types/Rect';
+import { useDimensions } from '../hooks/useDimensions';
import { useTheme } from '../hooks/useTheme';
import { Box } from '../layout';
@@ -11,6 +13,7 @@ export const DefaultTourMask = memo(
({ activeTourStepTarget, padding, borderRadius = 12 }: TourMaskComponentProps) => {
const [rect, setRect] = useState(defaultRect);
const theme = useTheme();
+ const { statusBarHeight } = useDimensions();
const overlayFillRgba = theme.color.bgOverlay;
const defaultPadding = theme.space[2];
@@ -27,10 +30,19 @@ export const DefaultTourMask = memo(
);
useEffect(() => {
- activeTourStepTarget?.measureInWindow((x, y, width, height) =>
- setRect({ x, y, width, height }),
- );
- }, [activeTourStepTarget]);
+ activeTourStepTarget?.measureInWindow((x, y, width, height) => {
+ // On Android, measureInWindow returns coordinates relative to the app's visible area.
+ // The Modal's coordinate system starts from the screen top (y=0 at very top of display).
+ // In edge-to-edge mode (statusBarHeight > 0), the app extends behind the status bar,
+ // and measureInWindow returns y relative to below the status bar. We need to ADD
+ // statusBarHeight to convert to screen coordinates for the Modal.
+ // In non-edge-to-edge mode (statusBarHeight === 0), measureInWindow returns y from
+ // screen top, but the Modal still starts from screen top, so no adjustment is needed.
+ const adjustedY = Platform.OS === 'ios' ? y : y + statusBarHeight;
+
+ setRect({ x, y: adjustedY, width, height });
+ });
+ }, [activeTourStepTarget, statusBarHeight]);
return (
diff --git a/packages/mobile/src/tour/Tour.tsx b/packages/mobile/src/tour/Tour.tsx
index ca2190a7e..a2ceca947 100644
--- a/packages/mobile/src/tour/Tour.tsx
+++ b/packages/mobile/src/tour/Tour.tsx
@@ -1,5 +1,5 @@
import React, { useCallback, useRef } from 'react';
-import { Modal, View } from 'react-native';
+import { Modal, Platform, View } from 'react-native';
import type { SharedProps } from '@coinbase/cds-common';
import {
OverlayContentContext,
@@ -27,6 +27,7 @@ import {
} from '@floating-ui/react-native';
import { animated, config as springConfig, useSpring } from '@react-spring/native';
+import { useDimensions } from '../hooks/useDimensions';
import { useTheme } from '../hooks/useTheme';
import { DefaultTourMask } from './DefaultTourMask';
@@ -116,6 +117,7 @@ const TourComponent = ({
testID,
}: TourProps) => {
const theme = useTheme();
+ const { statusBarHeight } = useDimensions();
const defaultTourStepOffset = theme.space[3];
const defaultTourStepShiftPadding = theme.space[4];
@@ -175,9 +177,18 @@ const TourComponent = ({
const handleActiveTourStepTargetChange = useCallback(
(target: View | null) => {
target?.measureInWindow((x, y, width, height) => {
+ // On Android, measureInWindow returns coordinates relative to the app's visible area.
+ // The Modal's coordinate system starts from the screen top (y=0 at very top of display).
+ // In edge-to-edge mode (statusBarHeight > 0), the app extends behind the status bar,
+ // and measureInWindow returns y relative to below the status bar. We need to ADD
+ // statusBarHeight to convert to screen coordinates for the Modal.
+ // In non-edge-to-edge mode (statusBarHeight === 0), measureInWindow returns y from
+ // screen top, but the Modal still starts from screen top, so no adjustment is needed.
+ const adjustedY = Platform.OS === 'ios' ? y : y + statusBarHeight;
+
refs.setReference({
measure: (callback: (x: number, y: number, width: number, height: number) => void) => {
- callback(x, y, width, height);
+ callback(x, adjustedY, width, height);
void animationApi.start({ to: { opacity: 1 }, config: springConfig.slow });
},
});
@@ -185,7 +196,7 @@ const TourComponent = ({
setActiveTourStepTarget(target);
},
- [animationApi, refs, setActiveTourStepTarget],
+ [animationApi, refs, setActiveTourStepTarget, statusBarHeight],
);
return (
diff --git a/packages/mobile/src/tour/__tests__/Tour.test.tsx b/packages/mobile/src/tour/__tests__/Tour.test.tsx
index 1d1ad49a0..24421cc9a 100644
--- a/packages/mobile/src/tour/__tests__/Tour.test.tsx
+++ b/packages/mobile/src/tour/__tests__/Tour.test.tsx
@@ -3,9 +3,15 @@ import { Button, Text } from 'react-native';
import { useTourContext } from '@coinbase/cds-common/tour/TourContext';
import { fireEvent, render, screen, waitFor } from '@testing-library/react-native';
+import { useDimensions } from '../../hooks/useDimensions';
import { DefaultThemeProvider } from '../../utils/testHelpers';
import { Tour, type TourProps } from '../Tour';
+jest.mock('../../hooks/useDimensions');
+const mockUseDimensions = (mocks: ReturnType) => {
+ (useDimensions as jest.Mock).mockReturnValue(mocks);
+};
+
const StepOne = () => {
const { goNextTourStep } = useTourContext();
@@ -51,6 +57,14 @@ const exampleProps: TourProps = {
};
describe('Tour', () => {
+ beforeEach(() => {
+ mockUseDimensions({
+ screenHeight: 844,
+ screenWidth: 390,
+ statusBarHeight: 47,
+ });
+ });
+
it('passes accessibility', async () => {
render(