Skip to content

Conversation

@chrisbarless
Copy link

@chrisbarless chrisbarless commented Oct 16, 2025

Problem

In React Native 0.80+, the Material Design 3 pill indicator in BottomNavigation.Bar exhibits several animation issues:

  1. Half-sized pill on initial render: The pill appears incomplete (half-sized) on first focus
  2. Incomplete animations during rapid tab switching: Spamming navigation buttons causes the pill to freeze in intermediate states
  3. Stuck ripple effects: Background ripple animations occasionally fail to complete
  4. Instant pill transitions: The old pill disappears instantly instead of animating out, making tab switching feel abrupt regardless of the shifting prop

These issues are specific to React Native 0.80+ and are caused by changes in how the native animation driver handles
concurrent animations and mount behavior.

Root Cause

The Animated API's native driver in RN 0.80+ has two problems:

  1. Mount animation interruption: Starting animations immediately after component mount causes the native driver to drop
    completion frames, leaving animated values in incomplete states
  2. Concurrent animation corruption: When new animations start while previous ones are in progress, the native driver doesn't
    properly sync state, resulting in orphaned animations

The legacy workaround from RN 0.57 (removed in this PR) exacerbates these issues rather than solving them.

Solution

This PR implements a fix with several layers of protection:

  1. Skip Animation on Initial Mount
const isInitialMount = React.useRef(true);

React.useEffect(() => {
  if (isInitialMount.current) {
    // Skip animation - values already correctly initialized
    isInitialMount.current = false;
    return;
  }
  animateToIndex(navigationState.index);
}, [navigationState.index, animateToIndex]);
  • Values are already correctly initialized by useAnimatedValue and useAnimatedValueArray
  • Skipping the mount animation prevents native driver from rendering incomplete frames
  • Standard React pattern using refs
  1. Graceful Animation Cancellation
// If already animating to this index, do nothing
if (animationInProgressRef.current && targetIndexRef.current === index) {
  return;
}

// If animating to different index, cancel and restart
if (animationInProgressRef.current) {
  rippleAnim.stopAnimation();
  indexAnim.stopAnimation();
  tabsAnims.forEach((tab) => tab.stopAnimation());
}
  • Prevents concurrent animations to the same target (optimization)
  • Cancels in-progress animations when switching targets (prevents half-states)
  • Provides immediate feedback during rapid tab switching
  1. Enhanced Cleanup with Fallback
]).start((result) => {
  animationInProgressRef.current = false;

  if (targetIndexRef.current !== index) {
    return;
  }

  // Explicit cleanup to ensure completion
  tabsAnims.forEach((tab, i) => {
    tab.stopAnimation();
    tab.setValue(i === index ? 1 : 0);
  });
  indexAnim.stopAnimation();
  indexAnim.setValue(index);
  rippleAnim.stopAnimation();
  rippleAnim.setValue(MIN_RIPPLE_SCALE);

  // Safety fallback for interrupted animations
  if (result?.finished === false) {
    requestAnimationFrame(() => {
      // Ensure completion on next frame
    });
  }
});
  • Explicit stopAnimation() + setValue() forces native driver sync
  • Target validation prevents stale completions
  • requestAnimationFrame fallback catches edge cases
  1. Removed Obsolete RN 0.57 Workaround
  • The initial useEffect with empty dependencies is removed
  • This workaround from 2018 (RN 0.57) is no longer needed and causes problems in modern RN
  1. Fixed Pill Visibility and Animation

The pill indicator was only rendered when focused === true, causing instant appearance/disappearance instead of smooth animations. Additionally, the outlineScale interpolation for unfocused tabs was inverted, making unfocused pills appear at full width.

Original Code:

{
  isV3 &&
    focused && ( // Only renders when focused - no exit animation possible!
      <Animated.View style={{ transform: [{ scaleX: outlineScale }] }} />
    );
}

const outlineScale = focused
  ? active.interpolate({
      inputRange: [0, 1],
      outputRange: [0.5, 1],
    })
  : 0; // Instant disappearance

Fixed Code:

{
  isV3 && ( // Always renders - enables smooth enter/exit animations
    <Animated.View style={{ transform: [{ scaleX: outlineScale }] }} />
  );
}

const outlineScale = focused
  ? active.interpolate({
      inputRange: [0, 1],
      outputRange: [0.5, 1], // Focused: grows from half to full
    })
  : active.interpolate({
      inputRange: [0, 1],
      outputRange: [0, 1], // Unfocused: shrinks from full to zero
    });
  • Pill element always exists in the DOM, allowing the scaleX animation to work on both enter and exit
  • When active = 0 (unfocused), scaleX = 0 (invisible)
  • When transitioning from focused to unfocused, active animates from 1 → 0, causing scaleX to smoothly shrink from 1 → 0
  • When transitioning from unfocused to focused, active animates from 0 → 1, causing scaleX to smoothly grow from 0.5 → 1
  • This creates coordinated enter/exit animations where the old pill shrinks out while the new pill grows in

Testing

Tested on:

  • Platform: iOS 26, Android 15
  • React Native: 0.81.4
  • react-native-paper: 5.14.5

Scenarios tested:

  • Initial page load with default tab
  • Initial page load with non-default tab (no visible jump)
  • Normal tab switching
  • Rapid tab switching (spam clicking)
  • Animation completion during navigation state changes
  • Badge count updates during animations

Results:

  • No half-sized pills on any render
  • Smooth animations during normal use
  • Immediate feedback during rapid switching
  • No stuck or incomplete states

Note

Ensures bottom navigation animations complete reliably, skip mount animation, gracefully cancel/restart during rapid switches, and always render the V3 pill for smooth enter/exit.

  • BottomNavigation.Bar:
    • Animation control: Add refs to track target/progress; prevent duplicate runs; cancel in-flight animations; explicit stopAnimation() + setValue() on completion; rAF fallback for interrupted runs.
    • Initial mount: Skip first animation to avoid incomplete native frames.
    • V3 pill indicator: Always render the outline; fix outlineScale for unfocused state to enable smooth shrink/grow transitions.
    • Remove obsolete RN 0.57 workaround useEffect.
  • Tests:
    • Update snapshots to include persistent pill outline and new animated states.

Written by Cursor Bugbot for commit 3d358b8. This will update automatically on new commits. Configure here.

cursor[bot]

This comment was marked as outdated.

@callstack-bot
Copy link

callstack-bot commented Oct 16, 2025

Hey @chrisbarless, thank you for your pull request 🤗. The documentation from this branch can be viewed here.

@chrisbarless chrisbarless force-pushed the fix/bottom-navigation-animation-completion branch from c6ec1cd to 866eeba Compare October 16, 2025 00:56
@CatLover01
Copy link

CatLover01 commented Oct 29, 2025

When shifting is set to true, the tab animation works as expected, the icon shifts up and the transition animates smoothly (slide/fade). So your fix solves part of the problem.

However, with shifting set to false, the animation is now instant with no horizontal transition, whereas before the React Native update it used to animate horizontally (without the icon shifting up). Ideally, I’d like the horizontal animation to still work even when shifting is false, just without the icon moving up.

@chrisbarless chrisbarless marked this pull request as draft October 30, 2025 23:34
@chrisbarless
Copy link
Author

When shifting is set to true, the tab animation works as expected, the icon shifts up and the transition animates smoothly (slide/fade). So your fix solves part of the problem.

However, with shifting set to false, the animation is now instant with no horizontal transition, whereas before the React Native update it used to animate horizontally (without the icon shifting up). Ideally, I’d like the horizontal animation to still work even when shifting is false, just without the icon moving up.

On second look, I think you might be right. I'll keep working on it

@chrisbarless chrisbarless force-pushed the fix/bottom-navigation-animation-completion branch from 866eeba to 276f258 Compare October 31, 2025 05:23
@chrisbarless
Copy link
Author

This approach seems to work better @CatLover01, want to have a look and let me know what you think?

@CatLover01
Copy link

This approach seems to work better @CatLover01, want to have a look and let me know what you think?

The code looks good! I just tested it and it works perfectly on my side for non-shifting. Thanks!

@chrisbarless chrisbarless marked this pull request as ready for review October 31, 2025 14:56
: active.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Bug

The outline scale animation has a visual discontinuity when a tab becomes focused. The scale abruptly jumps from 0 to 0.5 at the start of the transition, instead of animating smoothly from 0, creating a jarring visual effect. This happens because the focused state changes instantly, altering the outlineScale interpolation range before the active animation begins.

Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants