Navigation Transitions

Zoom

Use bounds(id).navigation.zoom() for opinionated bounds-driven source-to-destination navigation transitions.

What This Helper Is

navigation.zoom() is the high-level bounds helper for navigation-driven zoom transitions.

It sits on top of the same boundary link system as bounds(), but instead of manually composing slot output yourself, it builds the main layer map for you.

Use it when you want a source-to-destination zoom without building the transition from low-level bounds primitives.

1. Mark the source with Boundary

Use Transition.Boundary on the source side.

When onPress is present, Boundary behaves like a pressable boundary and captures source bounds before your navigation callback runs.

TSX

1<Transition.Boundary
2 id={item.id}
3 anchor="leading"
4 scaleMode="uniform"
5 style={styles.row}
6 onPress={() => navigation.navigate("Detail", { id: item.id })}
7>
8 <Image source={item.image} style={styles.artwork} />
9</Transition.Boundary>

2. Narrow measurement with Boundary.Target when needed

If the pressable owner is larger than the visual element you actually want to measure, keep the boundary as the owner and move measurement to a nested Transition.Boundary.Target.

Boundary.Target does not define its own id. It inherits the surrounding boundary owner and only changes which descendant gets measured.

TSX

1<Transition.Boundary
2 id={item.id}
3 anchor="leading"
4 scaleMode="uniform"
5 style={styles.row}
6 onPress={() => navigation.navigate("Detail", { id: item.id })}
7>
8 <Transition.Boundary.Target style={styles.artTarget}>
9 <Image source={item.image} style={styles.artwork} />
10 </Transition.Boundary.Target>
11
12 <View style={styles.copy}>
13 <Text>{item.title}</Text>
14 </View>
15</Transition.Boundary>

3. Run navigation.zoom() in the destination screen options

Put the helper inside the destination screen's screenStyleInterpolator.

TSX

1import { makeMutable } from "react-native-reanimated";
2
3const navigationZoomId = makeMutable<string | null>(null);
4
5<Transition.Boundary
6 id={item.id}
7 onPress={() => {
8 navigationZoomId.value = item.id;
9 navigation.navigate("Detail");
10 }}
11>
12 <Image source={item.image} style={styles.artwork} />
13</Transition.Boundary>
14
15<Stack.Screen
16 name="[id]"
17 options={{
18 navigationMaskEnabled: Platform.OS === "ios",
19 gestureEnabled: true,
20 gestureDirection: ["vertical", "vertical-inverted", "horizontal"],
21 transitionSpec: Transition.Specs.Zoom,
22 screenStyleInterpolator: ({ bounds }) => {
23 "worklet";
24 const id = navigationZoomId.value;
25
26 if (!id) {
27 return null;
28 }
29
30 return bounds(id).navigation.zoom({
31 borderRadius: 48,
32 });
33 },
34 }}
35/>

The id does not have to come from route params. You can resolve it from route params, a Reanimated shared value created with makeMutable, Zustand-backed state, or any other source, as long as the interpolator can read the active boundary id.

Transition.Specs.Zoom provides the paired open and close springs tuned for this helper. navigation.zoom() builds the visual path, while transitionSpec controls how that path settles in each direction.

Zoom uses transition progress internally for its bounds path. Live gesture values still drive drag and release effects, and gesture.handoff keeps release-time values available while live gesture fields reset.

Destination Setup

The destination screen does not need a passive Transition.Boundary for navigation.zoom() to work.

If all you need is a source-driven navigation zoom into the detail screen, the source Boundary plus the interpolator lookup is enough.

This is the simpler setup and is often all you need for id-based list-to-detail flows.

Add a matching passive Transition.Boundary on the destination when the zoom should target a specific destination element instead of the screen.

zoom() Options

navigation.zoom() accepts an optional configuration object:

TSX

1return bounds(id).navigation.zoom({
2 borderRadius: 48,
3 target: "bound",
4 backgroundScale: 0.96,
5 backdropColor: "#000",
6 backdropOpacity: 0.45,
7 drag: {
8 translation: { horizontal: 0.9, vertical: 0.85 },
9 scale: { horizontal: 1, vertical: 0.8 },
10 },
11});
OptionTypeNotes
target"bound" | "fullscreen" | MeasuredDimensionsUses the paired destination boundary, the full screen, or an explicit rectangle. When omitted, zoom expands to a full-width rectangle that preserves the source aspect ratio.
keepFocusedVisiblebooleanKeeps focused content visible instead of applying zoom's built-in focused-content fade. Defaults to false. Useful for handed-off or other live content.
borderRadiusnumberExpanded clipping radius. Zoom interpolates from the measured source radius to this value. Defaults to 64.
backgroundScalenumberScale applied to the unfocused screen. Defaults to 0.9375.
backdropColorColorValueColor behind the focused zoom content. Defaults to "black".
backdropOpacitynumberMaximum backdrop opacity, clamped to 0...1. Defaults to 0.45.
drag.translation.horizontalnumberHorizontal rendered-translation response. Defaults to 1.
drag.translation.verticalnumberVertical rendered-translation response. Defaults to 1.
drag.scale.horizontalnumberHorizontal drag-scale response. Defaults to 1.
drag.scale.verticalnumberVertical drag-scale response. Defaults to 1.

The nested drag values adjust intensity without replacing zoom's built-in curves. 0 disables that response on the axis, 1 keeps the default response, and values above 1 exaggerate it.

The following earlier beta options still compile for migration safety, but zoom ignores them:

  • debug
  • focusedElementOpacity
  • unfocusedElementOpacity
  • maxSensitivity
  • velocityDepth
  • gestureProgressMode
  • horizontalDragScale
  • verticalDragScale
  • horizontalDragTranslation
  • verticalDragTranslation

Move axis response tuning to drag. Zoom now owns opacity timing, gesture sensitivity, and velocity-depth behavior as part of its native preset.

Interactive Dismissal

Zoom responds to every enabled pan direction and to pinch-in.

TSX

1options={{
2 gestureEnabled: true,
3 gestureDirection: ["bidirectional", "pinch-in"],
4 transitionSpec: Transition.Specs.Zoom,
5 screenStyleInterpolator: ({ bounds }) => {
6 "worklet";
7 return bounds("hero").navigation.zoom();
8 },
9}}

Rotation runs alongside an active pinch. Zoom keeps the pinch's activation point anchored while scale and rotation change, then carries the same origin through the release handoff.

navigationMaskEnabled is optional for navigation.zoom() itself.

Enable it when you want masked reveal behavior during the navigation handoff:

TSX

1<Stack.Screen
2 name="[id]"
3 options={{
4 navigationMaskEnabled: Platform.OS === "ios",
5 transitionSpec: Transition.Specs.Zoom,
6 screenStyleInterpolator: ({ bounds }) => {
7 "worklet";
8 return bounds("hero").navigation.zoom();
9 },
10 }}
11/>

Install @react-native-masked-view/masked-view when you enable navigationMaskEnabled.

Masked navigation transitions can cost more on Android. If you do not specifically need the masked reveal there, prefer leaving navigationMaskEnabled off on Android.

Groups

Most navigation zoom flows do not need groups.

If each source item navigates to its own destination route and the match is simply id -> route, plain id matching is enough.

Use group when the active matched member can change while the destination flow stays mounted, such as a paged gallery or a retargeted collection detail flow.

TSX

1<Transition.Boundary group="gallery" id={item.id} onPress={openItem}>
2 <Image source={item.image} />
3</Transition.Boundary>
4
5<Transition.Boundary group="gallery" id={item.id}>
6 <Image source={item.image} />
7</Transition.Boundary>

In that setup, some shared state usually tracks which id is currently active inside the group so navigation.zoom() knows which member to resolve against.