Skip to main content
· 10 min readRNTutorials
Malik Chohra

By Malik Chohra

How to Add Animation in Navigation with React Navigation

Master navigation animations in React Native. Learn default transitions, custom animations, animated headers, tab bars, and gesture-based interactions.

Navigation Animations in React Navigation: What Actually Works

Navigation transitions are one of those things that quietly determine how polished your app feels. Get them right and nobody notices. Get them wrong and every screen change feels like a web view. React Navigation gives you a lot of control here, but the API surface is large enough that most developers stick to the presets and never dig deeper. This article covers the three animation types you actually need to understand, how React Native Reanimated 3 changes the picture, how to write a real custom transition without guessing at the interpolator math, and how to verify none of it is dropping frames before you ship.

The Three Animation Types and When to Use Each

React Navigation splits navigation into three structural patterns, and each one has a different animation contract. Understanding which pattern maps to which part of your app saves you from fighting the defaults.

Stack navigation is the most common. The default behavior on iOS is a horizontal slide from right with a parallax shadow on the outgoing card. On Android it is a vertical lift with a fade. Both feel native because they mirror what the OS does. You reach for stack navigation when you have a linear hierarchy: a list that drills into a detail, a settings flow, an onboarding sequence. The animation communicates depth. The user is going deeper or coming back up.

Tab navigation does not animate the screen content by default, which surprises most people. The tabs themselves animate (the indicator, the icon scale), but switching tabs is an instant swap. This is intentional. Tabs model peer screens, not depth, and cross-fading between them reads as disorienting because it implies a direction that does not exist. If you want a swipe gesture between tabs you can add it, but you should treat that as a deliberate product decision rather than a default. The pattern works best for apps where the user needs to jump between unrelated contexts: feed, search, profile.

Modal presentation slides up from the bottom on both platforms. The visual metaphor is a sheet layered on top of the current context. You reach for this when the user needs to complete a task without leaving where they are: a filter panel, a compose screen, a quick action. The animation should be fast and feel physical, like lifting a card. If your modal is slow to open or bounces after it arrives, users unconsciously read it as heavy and that feeling transfers to the whole app.

Reanimated 3 vs. useNativeDriver: What Actually Runs on the UI Thread

The older approach to smooth animations in React Navigation was to pass useNativeDriver: true to an Animated.Value. This serialized the animation to the native side so that JavaScript thread jank would not drop frames. It worked, and it still works. The limitation is that useNativeDriver only supports a constrained set of transform and opacity properties. You cannot use it to animate layout values like width, height, or padding, which means any transition that needs to reshape a card is back on the JS thread and subject to garbage collection pauses.

React Native Reanimated 3 changes the model. Instead of serializing animation commands from the JS thread, Reanimated 3 runs a full JavaScript worklet directly on the UI thread using a separate JS engine (JSI-based, not the bridge). The practical consequence is that your interpolation logic runs at the same priority as the compositor, and you can read and write any style property without touching the JS thread at all. When React Navigation uses Reanimated internally, the entire transition lives on the UI thread. A blocked JS thread caused by a heavy data fetch in a useEffect will not stutter the transition because the two are no longer competing on the same thread.

The important thing to know is that React Navigation's @react-navigation/stack package supports both modes. If Reanimated is installed and the version is compatible, the stack navigator uses it automatically. If you are on the native stack from @react-navigation/native-stack, the animations are fully native and you get even less control but the most consistent 60fps behavior because the transition runs in the platform's own navigation system.

Writing a Real transitionSpec: Spring vs. Timing and When Spring Feels Wrong

The transitionSpec prop controls the animation curve and duration. You have two options: SpringTransition and TimingTransition. Most tutorials reach for spring immediately because it looks good in isolation. The problem is that spring animations do not have a fixed duration. A spring settles when its velocity drops below a threshold, and the time it takes depends on the stiffness and damping you configure. For navigation, this creates a real usability problem.

Imagine a productivity app where the user taps through a list of tasks quickly. If each transition has a spring that takes 400-500ms to settle, the user is waiting almost half a second per navigation event. The animation feels sluggish because it is literally slower than the user. The visual bounciness that makes spring appealing in a hero section of a marketing site is exactly what makes it feel wrong in a task-dense interface. Spring works well for modals, where the sheet lifting from the bottom benefits from a physical feel, and for onboarding, where the user is not in a hurry. For stack navigation in any app that prioritizes task completion over delight, timing is the right default.

import { Easing } from 'react-native';

// Spring — good for modals and onboarding, bad for fast task flows
const springTransition = {
  open: {
    animation: 'spring',
    config: {
      stiffness: 200,
      damping: 30,
      mass: 1,
      overshootClamping: false,
      restDisplacementThreshold: 0.01,
      restSpeedThreshold: 0.01,
    },
  },
  close: {
    animation: 'spring',
    config: {
      stiffness: 200,
      damping: 30,
      mass: 1,
      overshootClamping: true, // clamp on close, no bounce back
      restDisplacementThreshold: 0.01,
      restSpeedThreshold: 0.01,
    },
  },
};

// Timing — predictable, respects the user's pace
const timingTransition = {
  open: {
    animation: 'timing',
    config: {
      duration: 220,
      easing: Easing.out(Easing.poly(4)),
    },
  },
  close: {
    animation: 'timing',
    config: {
      duration: 180, // close faster, user already moved on
      easing: Easing.in(Easing.poly(4)),
    },
  },
};

<Stack.Screen
  name="Detail"
  component={DetailScreen}
  options={{ transitionSpec: timingTransition }}
/>

Notice the close duration is shorter than the open. This is intentional. The user triggered the close, so they are already mentally moving on. A fast close transition respects that. A slow close that mirrors the open feels like the UI is arguing with them. This asymmetry is something the default presets often miss.

Building a Custom cardStyleInterpolator with AI Assistance

The cardStyleInterpolator is where most developers give up because the interpolation math requires you to think in terms of progress values (0 to 1), incoming screens, outgoing screens, and layout dimensions all at once. Claude Code is genuinely useful here because the problem is well-defined: you describe the visual behavior you want and it can generate the interpolation correctly on the first attempt more often than not.

Here is an example prompt that works well: “Write a React Navigation cardStyleInterpolator that slides the incoming screen in from the right while scaling the outgoing screen down to 0.92 and fading it to 0.7 opacity. The incoming card should have no opacity animation, just the slide.” The model understands the current, next, and layouts destructured arguments and knows that next is the outgoing card when navigating forward. Being specific about what the outgoing screen does is the key detail. That is where most interpolators get wrong because developers forget that next is only defined on the outgoing screen during a forward push.

// Generated result — verified working
const slideWithScaleInterpolator = ({ current, next, layouts }) => {
  const translateX = current.progress.interpolate({
    inputRange: [0, 1],
    outputRange: [layouts.screen.width, 0],
  });

  const outgoingScale = next
    ? next.progress.interpolate({
        inputRange: [0, 1],
        outputRange: [1, 0.92],
      })
    : 1;

  const outgoingOpacity = next
    ? next.progress.interpolate({
        inputRange: [0, 1],
        outputRange: [1, 0.7],
      })
    : 1;

  return {
    cardStyle: {
      transform: [{ translateX }],
    },
    containerStyle: {
      transform: [{ scale: outgoingScale }],
      opacity: outgoingOpacity,
    },
  };
};

<Stack.Screen
  name="Detail"
  component={DetailScreen}
  options={{ cardStyleInterpolator: slideWithScaleInterpolator }}
/>

This pattern, where the incoming screen slides while the outgoing screen scales down behind it, is similar to what Apple uses for the Files app folder open transition. It communicates depth without the full parallax complexity of the default iOS stack. When asking a model to generate variants, always include what both screens should do. Prompts that only describe the incoming screen produce interpolators that leave the outgoing screen completely static, which feels disconnected.

The 60fps Test: Using the Performance Monitor to Catch Frame Drops

Writing a smooth-looking interpolator in isolation is not the same as having a smooth transition in a real app. The React Native Performance Monitor (accessible via the developer menu on device or simulator) shows two numbers: the JS thread frame rate and the UI thread frame rate. Both should stay at or near 60fps during a transition. Most transition problems show up on one of two specific triggers: the moment navigation is called, and the first frame after the incoming screen mounts.

The most common failure mode is a JS thread drop at the moment navigation is triggered. This happens when the screen being navigated to runs expensive code in its initial render. The transition starts, the new screen mounts, its useEffect fires a network request, the JS thread spikes, and the animation stutters. The fix is to defer expensive work until after the transition completes. React Navigation exposes a listener for exactly this:

import { useNavigation } from '@react-navigation/native';

function ExpensiveScreen() {
  const navigation = useNavigation();
  const [isReady, setIsReady] = React.useState(false);

  // Wait for the transition animation to finish before heavy work
  React.useEffect(() => {
    const unsubscribe = navigation.addListener('transitionEnd', () => {
      setIsReady(true);
    });
    return unsubscribe;
  }, [navigation]);

  if (!isReady) return <SkeletonLoader />;
  return <HeavyContent />;
}

When profiling, trigger the transition five times in a row, quickly. Rapid repeat navigations are where spring transitions start showing their settling latency and where any work done on mount compounds. If you see UI thread drops rather than JS thread drops, the issue is in the interpolator itself. The two most common causes are: animating a layout property that does not run on the UI thread with the current setup, or using a box shadow or elevation value that triggers expensive layer compositing on Android. Elevation on Android is particularly expensive during transitions and is worth disabling during the animation if you see UI thread drops on that platform specifically.

Testing on a physical mid-range Android device, not the simulator, is non-negotiable for this validation. The simulator does not throttle the JS thread the way a real device does under load, and Android compositing behavior differs substantially from iOS. A transition that looks perfect in the Xcode simulator on an M1 Mac can stutter on a Pixel 4a with background apps running. Budget the time to test on real hardware before shipping animated navigation.

What AI Mobile Launcher Ships Out of the Box

Setting up Reanimated correctly is not hard, but it is a sequence of steps that has to be done right: install the package, add the Babel plugin, configure the metro resolver, verify the JSI bridge is working on both platforms. Getting any step wrong produces silent failures where animations fall back to the JS-thread Animated API without any warning. You will not see an error. The animations will just be slightly less smooth and you will not know why.

AI Mobile Launcher ships with Reanimated 3 pre-wired and verified against the current Expo SDK. The boilerplate includes a set of custom transitions that were built to match the design system: a timing-based stack transition for task flows using the asymmetric open/close pattern described above, a spring-based modal sheet for overlays where the physical feel is appropriate, and a scale-plus-fade for settings screens. The interpolators live in a single transitions file, which means you can swap them per screen without touching the navigator configuration.

The 60fps baseline was validated on a mid-range Android device running alongside a data fetch in the background, which is the realistic worst-case scenario. The transition-end deferral pattern for expensive screens is implemented by default in the screen template, so you do not have to remember to add it. If you are starting a new React Native project and want navigation animations that work without the configuration overhead, this is what the boilerplate gives you. You can override any transition per screen, but the defaults are production-quality from day one.