Core Concepts

Bounds Transitions

Use Transition.Boundary and the scoped bounds accessor to sync geometry across screens.

What This System Is

Bounds Transitions animate matched geometry across screens by measuring a source boundary and a destination boundary, then moving screen A and screen B together so the result reads like shared element motion.

If you are specifically looking for true shared element transitions, wait for Reanimated Shared Element Transitions to become production-ready. This package's bounds system is a different approach.

Boundary Pattern

Use Transition.Boundary for both interactive and passive boundaries.

When onPress is present, the boundary behaves like a pressable source. When onPress is omitted, it behaves like a passive measured view.

TSX

1// Source screen
2<Transition.Boundary
3 id="hero"
4 onPress={() => navigation.navigate("Detail")}
5>
6 <Image source={heroImage} style={{ width: 120, height: 160 }} />
7</Transition.Boundary>
8
9// Destination screen
10<Transition.Boundary id="hero">
11 <Image source={heroImage} style={{ width: "100%", height: 360 }} />
12</Transition.Boundary>

Transition.Boundary.View and Transition.Boundary.Trigger still exist as deprecated aliases, but new code should use Transition.Boundary.

For the high-level bounds(id).navigation.zoom() helper, the destination boundary is optional unless the zoom should target a specific destination element. See Zoom.

Nested Measurement with Boundary.Target

Use Transition.Boundary.Target when the pressable owner is larger than the visual element you want to measure.

TSX

1<Transition.Boundary
2 id={item.id}
3 anchor="leading"
4 scaleMode="uniform"
5 style={styles.row}
6 onPress={() => navigation.navigate("Playlist")}
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>

This keeps the row as the interaction owner while limiting measurement to the nested artwork.

The bounds() Accessor

Inside screenStyleInterpolator, call bounds(id) to scope the accessor to one boundary identity.

This accessor replaces the store-shaped bounds helpers from v3.8. The removal is source-breaking for callers of getMeasured, getSnapshot, getLink, interpolateStyle, interpolateBounds, or BoundsLink.compute. See New in 3.9 for the full migration table.

TSX

1screenStyleInterpolator: ({ bounds, progress, focused }) => {
2 "worklet";
3
4 return {
5 hero: {
6 style: {
7 ...bounds("hero").styles({
8 method: "transform",
9 }),
10 opacity: focused
11 ? interpolate(progress, [0, 1], [0, 1], "clamp")
12 : interpolate(progress, [1, 2], [1, 0], "clamp"),
13 },
14 },
15 };
16};

The scoped accessor exposes:

  • bounds(id).styles(options) for render-ready animated styles
  • bounds(id).values(options) for numeric geometry
  • bounds(id).link(id?) for inspecting the active source/destination link
  • bounds(id).navigation.zoom(options) for the built-in zoom recipe
  • bounds(id).navigation.reveal(options) for the built-in reveal recipe

Bound identities can be a string, a number, or an object with { id, group }.

TSX

1const card = bounds({ group: "gallery", id: item.id });
2const style = card.styles({ method: "size" });
3const link = card.link();

Bounds Options

Pass identity to bounds(), then pass compute options to styles() or values().

OptionValuesNotes
method"transform" | "size" | "content"Matching strategy
target"bound" | "fullscreen" | MeasuredDimensionsDestination target
anchor"topLeading" through "bottomTrailing"Matching anchor point
scaleMode"match" | "none" | "uniform"Aspect-ratio behavior
offset{ x?: number; y?: number }Add live x/y offsets from gestures, scroll, or animation state
progressnumberCustom progress driver for the bounds interpolation
motionBoundsMotionReplace the generated transform path without reimplementing measurement
gestures{ x?: number; y?: number }Deprecated alias for offset
space"relative" | "absolute"absolute is mainly for navigation helpers and custom renderers

Numeric Bounds with values()

Use styles() when you want the library to return the final animated style object.

Use values() when you want the same measured geometry but need to compose the final style yourself.

TSX

1screenStyleInterpolator: ({ bounds, current }) => {
2 "worklet";
3
4 const hero = bounds("hero").values({
5 method: "size",
6 space: "absolute",
7 progress: current.transitionProgress,
8 });
9
10 return {
11 hero: {
12 style: {
13 width: hero.width,
14 height: hero.height,
15 transform: [
16 { translateX: hero.translateX },
17 { translateY: hero.translateY },
18 ],
19 },
20 },
21 };
22};

Zoom and Reveal

For navigation-style bounds transitions, use the navigation helpers:

TSX

1screenStyleInterpolator: ({ bounds }) => {
2 "worklet";
3
4 return bounds("hero").navigation.zoom();
5};

For masked source-to-container transitions, use navigation.reveal():

TSX

1screenStyleInterpolator: ({ bounds, active }) => {
2 "worklet";
3
4 const id = active.route.params?.revealId;
5 if (!id) return null;
6
7 return bounds(id).navigation.reveal({
8 borderRadius: 48,
9 });
10};

See Zoom and Reveal for the full setup.

Groups

group is for flows where the active member can change inside the same collection, such as galleries, paged detail screens, or retargeted list/detail flows.

TSX

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

The group keeps track of the active member id so the transition stays aligned as focus moves between items in the same collection.

Live Payload Handoff

Use handoff when the same live payload should move between matching boundaries instead of remounting on each screen.

Live payload handoff is experimental in v3.9. It works well for focused flows, but native-backed content and rapid navigation can still expose platform-specific lifecycle or compositing edge cases.

TSX

1// Source
2<Transition.Boundary id="video" handoff onPress={openPlayer}>
3 <Transition.Boundary.Target style={styles.video}>
4 <VideoView player={player} style={StyleSheet.absoluteFill} />
5 </Transition.Boundary.Target>
6</Transition.Boundary>
7
8// Destination
9<Transition.Boundary id="video" handoff>
10 <Transition.Boundary.Target style={styles.heroVideo} />
11</Transition.Boundary>

Every matching boundary that should participate in the handoff should set handoff. The destination defines the receiving bounds; it does not need to render a second copy of the live child.

Use escapeClipping when a boundary needs to render outside local clipping or layout constraints during the transition.

TSX

1<Transition.Boundary
2 id="card"
3 escapeClipping
4 onPress={() => navigation.navigate("Detail")}
5>
6 <Image source={image} style={styles.cardImage} />
7</Transition.Boundary>

handoff and escapeClipping use react-native-teleport under the hood. Install the supported version when using either prop:

bun add react-native-teleport@1.1.10

The supported range is >=1.1.0 <1.1.11. Version 1.1.11 is not compatible with continuously animated boundary hosts.

Test handed-off video on physical Android devices. A SurfaceView may not obey portal clipping or transforms, while a TextureView participates in normal view composition but can briefly flicker while its native surface moves between hosts. For Expo Video, surfaceType="textureView" is usually the correct choice when clipping and transforms matter.

See Caveats & Trade-offs for the current handoff support boundary.

Bounds with Offsets

Because styles() and values() accept an offset option, you can feed any live x/y values into the matched transform.

TSX

1screenStyleInterpolator: ({ bounds, active }) => {
2 "worklet";
3
4 return {
5 hero: {
6 style: bounds("hero").styles({
7 offset: {
8 x: active.gesture.x,
9 y: active.gesture.y,
10 },
11 target: "fullscreen",
12 }),
13 },
14 };
15};

The older gestures option still works as a deprecated alias. Use offset for new code because the values can come from gestures, scroll, animation state, or any other x/y source.

Bounds with a Custom Progress Driver

By default, bounds use the visual frame progress, which includes live gesture movement.

Pass transitionProgress when the geometry should stay on the committed transition path while you layer gesture motion separately:

TSX

1screenStyleInterpolator: ({ bounds, current }) => {
2 "worklet";
3
4 return {
5 hero: {
6 style: bounds("hero").styles({
7 progress: current.transitionProgress,
8 offset: {
9 x: current.gesture.x,
10 y: current.gesture.y,
11 },
12 }),
13 },
14 };
15};

This is the replacement for older gestureProgressMode: "freeform" patterns in custom bounds animations.

Use link() when you need to inspect the active source/destination pair.

TSX

1const hero = bounds("hero");
2const link = hero.link();
3const initialSource = link?.initialSource;
4const initialDestination = link?.initialDestination;

Most animations should use styles(), values(), or the navigation helpers instead of reading the link directly. link() is useful when you are building custom choreography on top of the same measured pair.

Troubleshooting

Bounds Mismatch

Source, destination, and interpolator lookups must all resolve to the same boundary identity.

TSX

1// Source
2<Transition.Boundary id="hero">...</Transition.Boundary>
3
4// Destination
5<Transition.Boundary id="hero">...</Transition.Boundary>
6
7// Interpolator
8bounds("hero")

If you use groups, the same rule applies to the group + id pair.