Skip to main content

Deprecation Notice

Transition.MaskedView is deprecated in v3.4. For new code, use the built-in navigationMaskEnabled option instead. This guide documents the legacy approach for existing projects.
The new approach is simpler and requires no extra components:
import { Transition } from 'react-native-screen-transitions';

<Transition.Stack
  navigationMaskEnabled  // Built-in masking
  screenStyleInterpolator={screenStyleInterpolator}
  gestureEnabled
>
  {/* screens */}
</Transition.Stack>
This automatically provides masking for shared element reveal effects without manual setup.

Legacy: Transition.MaskedView

The old approach wraps the navigator with Transition.MaskedView:
// ❌ Deprecated approach
<Transition.MaskedView>
  <Transition.Stack
    screenStyleInterpolator={screenStyleInterpolator}
  >
    {/* screens */}
  </Transition.Stack>
</Transition.MaskedView>
This still works but is not recommended for new projects.

What is Masking?

Masking clips content to prevent visual overflow during shared element animations. Without masking, expanding elements may bleed outside their natural bounds. With masking enabled:
  • Shared elements clip properly during expansion
  • Reveal animations feel smooth and contained
  • Content doesn’t visually overflow during transitions

When You Need Masking

Shared Element Expansion

When animating an element from small (thumbnail) to large (fullscreen):
// Without masking: Element edges blur/clip badly
// With masking: Clean, contained expansion
<Transition.Boundary.Pressable id="hero" onPress={navigate}>
  <Image style={{ width: 80, height: 80, borderRadius: 8 }} />
</Transition.Boundary.Pressable>

<Transition.Boundary.View id="hero">
  <Image style={{ width: 300, height: 400, borderRadius: 0 }} />
</Transition.Boundary.View>

Shape-Changing Elements

When border radius or dimensions change:
// Expanding from rounded thumbnail to fullscreen
// Masking ensures the border radius transition looks smooth
<Transition.Stack navigationMaskEnabled>
  {/* screens with rounded thumbnail → fullscreen expansion */}
</Transition.Stack>

Reveal Effects

Progressively revealing content:
const revealInterpolator = ({ progress }) => {
  "worklet";
  return {
    content: {
      style: {
        borderRadius: interpolate(progress, [0, 1], [8, 0]),
      },
    },
  };
};
With masking, the reveal is clean and contained.

Performance Impact

Masking adds minor GPU overhead:
  • Modern devices: Negligible impact
  • Low-end devices: 1–3% performance cost
  • Conditional masking: Only enable when needed
Use navigationMaskEnabled selectively:
// Enable masking only for shared element stack
<Transition.Stack
  navigationMaskEnabled  // Only when shared elements present
  screenStyleInterpolator={sharedElementInterpolator}
>
  {/* screens */}
</Transition.Stack>

// Standard stack without shared elements—no masking
<Transition.Stack
  screenStyleInterpolator={simpleInterpolator}
>
  {/* screens */}
</Transition.Stack>

Legacy Implementation Details

If upgrading from old MaskedView usage, understand how it worked:

Masked View Hierarchy

<Transition.MaskedView>
  <Transition.Stack>
    {/* Screens rendered here, clipped by mask */}
  </Transition.Stack>
</Transition.MaskedView>
The MaskedView acted as a clipping boundary, preventing content from overflowing during animations.

Mask Shape

The mask shape followed the animation bounds:
// Mask adapts to expanding element bounds
// As element grows, mask expands with it
This required Reanimated 2/3 internals and was complex to maintain.

Why Deprecated

  1. Complex implementation: Required low-level mask management
  2. Platform differences: iOS and Android had different behavior
  3. Better alternative: navigationMaskEnabled is simpler and more reliable
  4. Performance: Built-in masking is optimized at the native level

Migration Path

Before (Old MaskedView)

// ❌ Old code
import { Transition } from 'react-native-screen-transitions';

<Transition.MaskedView>
  <Transition.Stack
    screenStyleInterpolator={({ progress, bounds }) => ({
      content: {
        style: {
          borderRadius: interpolate(progress, [0, 1], [8, 0]),
        },
      },
    })}
  >
    {/* screens */}
  </Transition.Stack>
</Transition.MaskedView>

After (New navigationMaskEnabled)

// ✅ New code
import { Transition } from 'react-native-screen-transitions';

<Transition.Stack
  navigationMaskEnabled
  screenStyleInterpolator={({ progress, bounds }) => ({
    content: {
      style: {
        borderRadius: interpolate(progress, [0, 1], [8, 0]),
      },
    },
  })}
>
  {/* screens */}
</Transition.Stack>
Just remove MaskedView wrapper and add navigationMaskEnabled prop.

Testing Masking Behavior

Verify masking is working by checking visual overflow:
// Test: Expanding shared element
const testInterpolator = ({ progress, bounds }) => {
  "worklet";
  const b = bounds({ id: "test" });

  return {
    content: {
      style: {
        // Element expands significantly
        transform: [
          { scale: interpolate(progress, [0, 1], [0.1, 2]) },
        ],
      },
    },
  };
};

<Transition.Stack
  navigationMaskEnabled
  screenStyleInterpolator={testInterpolator}
>
  {/* screens with expanding element */}
</Transition.Stack>
Without masking, the expanded element would overflow. With masking, it’s contained.

Visual Debugging

Enable debug mode to see mask boundaries:
// During development, visualize mask with debug style
const debugInterpolator = ({ progress, bounds }) => {
  "worklet";
  return {
    content: {
      style: {
        opacity: interpolate(progress, [0, 1], [0, 1]),
        // Add debug border to see bounds
        borderWidth: __DEV__ ? 1 : 0,
        borderColor: __DEV__ ? 'red' : 'transparent',
      },
    },
  };
};

Edge Cases

Overflow: Hidden

Ensure parents don’t have conflicting overflow settings:
// ✅ Let masking handle clipping
<Transition.Stack navigationMaskEnabled>
  {/* No overflow: hidden on parents */}
</Transition.Stack>

// ⚠️ May conflict
<View style={{ overflow: 'hidden' }}>
  <Transition.Stack navigationMaskEnabled>
    {/* Multiple clipping layers */}
  </Transition.Stack>
</View>

Transform Origin

Shared element transforms should use consistent origins:
// ✅ Consistent origin
transform: [
  { translateX: 0 },
  { scale: 1 },
]

// ⚠️ May clip unexpectedly
transform: [
  { translateX: 100 },
  { scale: 2 },
  { translateX: -50 },
]

Nested Masked Stacks

Avoid nesting multiple masked stacks:
// ⚠️ Avoid nesting
<Transition.Stack navigationMaskEnabled>
  <Transition.Modal navigationMaskEnabled>
    {/* Multiple mask layers */}
  </Transition.Modal>
</Transition.Stack>

// ✅ Better: Only outermost stack
<Transition.Stack navigationMaskEnabled>
  <Transition.Modal>
    {/* Inherits parent mask */}
  </Transition.Modal>
</Transition.Stack>

Troubleshooting

Content Clipping Unexpectedly

Check if masking is enabled:
// Verify navigationMaskEnabled is set
<Transition.Stack navigationMaskEnabled>
  {/* Should not clip unexpectedly */}
</Transition.Stack>

Reveal Animation Not Smooth

Ensure interpolator uses smooth easing:
// ❌ Jagged
borderRadius: interpolate(progress, [0, 0.5, 1], [8, 0, 0])

// ✅ Smooth
borderRadius: interpolate(progress, [0, 1], [8, 0])

Masking Disabled on Older Devices

navigationMaskEnabled works on all devices. If experiencing issues:
  1. Upgrade React Native to 0.73+
  2. Upgrade Reanimated to 3.0+
  3. Test on actual device (simulator may have issues)

Best Practices

  1. Use navigationMaskEnabled in modern code
  2. Only enable when needed (shared elements present)
  3. Test on low-end devices for performance
  4. Use smooth interpolations in animations
  5. Avoid nested masks in complex hierarchies
  6. Monitor performance with Profiler

Alternatives to Masking

If masking causes issues, consider:

Option 1: Simple Opacity

const simpleInterpolator = ({ progress }) => {
  "worklet";
  return {
    content: {
      style: {
        opacity: interpolate(progress, [0, 1], [0, 1]),
      },
    },
  };
};
No shape changes, so no masking needed.

Option 2: Smaller Expansion

// Expand 1x → 1.5x instead of 1x → 2x
const limitedInterpolator = ({ progress }) => {
  "worklet";
  return {
    content: {
      style: {
        transform: [
          { scale: interpolate(progress, [0, 1], [0.8, 1.5]) },
        ],
      },
    },
  };
};
Smaller expansion may not need masking.

Option 3: Pre-render Masked Assets

Prepare assets with appropriate bounds pre-rendered:
// Instead of animating shape, animate pre-rendered assets
<Image source={thumbnailImage} />  // Thumbnail
// Transition to
<Image source={expandedImage} />   // Expanded version pre-rendered

Summary

  • New code: Use navigationMaskEnabled
  • Legacy code: Transition.MaskedView still works but deprecated
  • Migration: Remove MaskedView, add navigationMaskEnabled
  • Performance: Negligible cost on modern devices
  • Best practices: Enable only when needed, use smooth animations
For most use cases, navigationMaskEnabled provides perfect results with zero configuration.

Next Steps