Support onGestureEnter/Exit/Share/Update events (#35556)
This is like the onEnter/Exit/Share/Update events but for gestures. It allows manually controlling the animation using the passed timeline.
Sebastian Markbåge committed
Jan 19, 2026 at 19:26 UTC
4bcf67e74657086ff7d8f951d6365db06cf1f72c
5 files changed
+154
-6
fixtures/view-transition/src/components/Page.js
+46
-1
@@ -86,6 +86,48 @@ export default function Page({url, navigate}) {
86
viewTransition.new.animate(keyframes, 250);
87
}
88
89
+ function onGestureTransition(
90
+ timeline,
91
+ {rangeStart, rangeEnd},
92
+ viewTransition,
93
+ types
94
+ ) {
95
+ const keyframes = [
96
+ {rotate: '0deg', transformOrigin: '30px 8px'},
97
+ {rotate: '360deg', transformOrigin: '30px 8px'},
98
+ ];
99
+ const reverse = rangeStart > rangeEnd;
100
+ if (timeline instanceof AnimationTimeline) {
101
+ // Native Timeline
102
+ const options = {
103
+ timeline: timeline,
104
+ direction: reverse ? 'normal' : 'reverse',
105
+ rangeStart: (reverse ? rangeEnd : rangeStart) + '%',
106
+ rangeEnd: (reverse ? rangeStart : rangeEnd) + '%',
107
+ };
108
+ viewTransition.old.animate(keyframes, options);
109
+ viewTransition.new.animate(keyframes, options);
110
+ } else {
111
+ // Custom Timeline
112
+ const options = {
113
+ direction: reverse ? 'normal' : 'reverse',
114
+ // We set the delay and duration to represent the span of the range.
115
+ delay: reverse ? rangeEnd : rangeStart,
116
+ duration: reverse ? rangeStart - rangeEnd : rangeEnd - rangeStart,
117
+ };
118
+ const animation1 = viewTransition.old.animate(keyframes, options);
119
+ const animation2 = viewTransition.new.animate(keyframes, options);
120
+ // Let the custom timeline take control of driving the animations.
121
+ const cleanup1 = timeline.animate(animation1);
122
+ const cleanup2 = timeline.animate(animation2);
123
+ // TODO: Support returning a clean up function from ViewTransition events.
124
+ // return () => {
125
+ // cleanup1();
126
+ // cleanup2();
127
+ // };
128
+ }
129
+ }
130
+
131
function swipeAction() {
132
navigate(show ? '/?a' : '/?b');
133
}
@@ -131,7 +173,10 @@ export default function Page({url, navigate}) {
173
);
174
175
const exclamation = (
134
- <ViewTransition name="exclamation" onShare={onTransition}>
176
+ <ViewTransition
177
+ name="exclamation"
178
+ onShare={onTransition}
179
+ onGestureShare={onGestureTransition}>
180
<span>
181
<div>!</div>
182
</span>
packages/react-reconciler/src/ReactFiberApplyGesture.js
+9
-1
@@ -82,6 +82,7 @@ import {
82
enableComponentPerformanceTrack,
83
} from 'shared/ReactFeatureFlags';
84
import {trackAnimatingTask} from './ReactProfilerTimer';
85
+import {scheduleGestureTransitionEvent} from './ReactFiberWorkLoop';
86
87
let didWarnForRootClone = false;
88
@@ -280,6 +281,7 @@ function applyAppearingPairViewTransition(child: Fiber): void {
281
if (clones !== null) {
282
applyViewTransitionToClones(name, className, clones, child);
283
}
284
+ scheduleGestureTransitionEvent(child, props.onGestureShare);
285
}
286
}
287
}
@@ -310,6 +312,11 @@ function applyExitViewTransition(placement: Fiber): void {
312
if (clones !== null) {
313
applyViewTransitionToClones(name, className, clones, placement);
314
}
315
+ if (state.paired) {
316
+ scheduleGestureTransitionEvent(placement, props.onGestureShare);
317
+ } else {
318
+ scheduleGestureTransitionEvent(placement, props.onGestureExit);
319
+ }
320
}
321
}
322
@@ -1123,7 +1130,8 @@ function applyViewTransitionsOnFiber(finishedWork: Fiber) {
1130
// TODO: If this doesn't end up canceled, because a parent animates,
1131
// then we should probably issue an event since this instance is part of it.
1132
} else {
1126
- // TODO: Schedule gesture events.
1133
+ const props: ViewTransitionProps = finishedWork.memoizedProps;
1134
+ scheduleGestureTransitionEvent(finishedWork, props.onGestureUpdate);
1135
// If this boundary did update, we cannot cancel its children so those are dropped.
1136
popViewTransitionCancelableScope(prevCancelableChildren);
1137
}
packages/react-reconciler/src/ReactFiberCommitViewTransitions.js
+6
-3
@@ -34,7 +34,10 @@ import {
34
hasInstanceAffectedParent,
35
wasInstanceInViewport,
36
} from './ReactFiberConfig';
37
-import {scheduleViewTransitionEvent} from './ReactFiberWorkLoop';
37
+import {
38
+ scheduleViewTransitionEvent,
39
+ scheduleGestureTransitionEvent,
40
+} from './ReactFiberWorkLoop';
41
import {
42
getViewTransitionName,
43
getViewTransitionClassName,
@@ -312,7 +315,7 @@ export function commitEnterViewTransitions(
315
316
if (!state.paired) {
317
if (gesture) {
315
- // TODO: Schedule gesture events.
318
+ scheduleGestureTransitionEvent(placement, props.onGestureEnter);
319
} else {
320
scheduleViewTransitionEvent(placement, props.onEnter);
321
}
@@ -848,7 +851,7 @@ export function measureNestedViewTransitions(
851
// Nothing changed.
852
} else {
853
if (gesture) {
851
- // TODO: Schedule gesture events.
854
+ scheduleGestureTransitionEvent(child, props.onGestureUpdate);
855
} else {
856
scheduleViewTransitionEvent(child, props.onUpdate);
857
}
packages/react-reconciler/src/ReactFiberWorkLoop.js
+64
-1
@@ -9,7 +9,11 @@
9
10
import {REACT_STRICT_MODE_TYPE} from 'shared/ReactSymbols';
11
12
-import type {Wakeable, Thenable} from 'shared/ReactTypes';
12
+import type {
13
+ Wakeable,
14
+ Thenable,
15
+ GestureOptionsRequired,
16
+} from 'shared/ReactTypes';
17
import type {Fiber, FiberRoot} from './ReactInternalTypes';
18
import type {Lanes, Lane} from './ReactFiberLane';
19
import type {ActivityState} from './ReactFiberActivityComponent';
@@ -26,6 +30,7 @@ import type {
30
Resource,
31
ViewTransitionInstance,
32
RunningViewTransition,
33
+ GestureTimeline,
34
SuspendedState,
35
} from './ReactFiberConfig';
36
import type {RootState} from './ReactFiberRoot';
@@ -913,6 +918,42 @@ export function scheduleViewTransitionEvent(
918
}
919
}
920
921
+export function scheduleGestureTransitionEvent(
922
+ fiber: Fiber,
923
+ callback: ?(
924
+ timeline: GestureTimeline,
925
+ options: GestureOptionsRequired,
926
+ instance: ViewTransitionInstance,
927
+ types: Array<string>,
928
+ ) => void,
929
+): void {
930
+ if (enableGestureTransition) {
931
+ if (callback != null) {
932
+ const applyingGesture = pendingEffectsRoot.pendingGestures;
933
+ if (applyingGesture !== null) {
934
+ const state: ViewTransitionState = fiber.stateNode;
935
+ let instance = state.ref;
936
+ if (instance === null) {
937
+ instance = state.ref = createViewTransitionInstance(
938
+ getViewTransitionName(fiber.memoizedProps, state),
939
+ );
940
+ }
941
+ const timeline = applyingGesture.provider;
942
+ const options = {
943
+ rangeStart: applyingGesture.rangeStart,
944
+ rangeEnd: applyingGesture.rangeEnd,
945
+ };
946
+ if (pendingViewTransitionEvents === null) {
947
+ pendingViewTransitionEvents = [];
948
+ }
949
+ pendingViewTransitionEvents.push(
950
+ callback.bind(null, timeline, options, instance),
951
+ );
952
+ }
953
+ }
954
+ }
955
+}
956
+
957
export function peekDeferredLane(): Lane {
958
return workInProgressDeferredLane;
959
}
@@ -4352,6 +4393,8 @@ function applyGestureOnRoot(
4393
startAnimating(pendingEffectsLanes);
4394
}
4395
4396
+ pendingViewTransitionEvents = null;
4397
+
4398
const prevTransition = ReactSharedInternals.T;
4399
ReactSharedInternals.T = null;
4400
const previousPriority = getCurrentUpdatePriority();
@@ -4476,6 +4519,26 @@ function flushGestureAnimations(): void {
4519
ReactSharedInternals.T = prevTransition;
4520
}
4521
4522
+ if (enableViewTransition) {
4523
+ // We should now be after the startGestureTransition's .ready call which is late enough
4524
+ // to start animating any pseudo-elements. We have also already applied any adjustments
4525
+ // we do to the built-in animations which can now be read by the refs.
4526
+ const pendingEvents = pendingViewTransitionEvents;
4527
+ let pendingTypes = pendingTransitionTypes;
4528
+ pendingTransitionTypes = null;
4529
+ if (pendingEvents !== null) {
4530
+ pendingViewTransitionEvents = null;
4531
+ if (pendingTypes === null) {
4532
+ // Normalize the type. This is lazily created only for events.
4533
+ pendingTypes = [];
4534
+ }
4535
+ for (let i = 0; i < pendingEvents.length; i++) {
4536
+ const viewTransitionEvent = pendingEvents[i];
4537
+ viewTransitionEvent(pendingTypes);
4538
+ }
4539
+ }
4540
+ }
4541
+
4542
if (enableProfilerTimer && enableComponentPerformanceTrack) {
4543
finalizeRender(lanes, commitEndTime);
4544
}
packages/shared/ReactTypes.js
+29
@@ -290,6 +290,11 @@ export type ViewTransitionClass =
290
| string
291
| ViewTransitionClassPerType;
292
293
+export type GestureOptionsRequired = {
294
+ rangeStart: number,
295
+ rangeEnd: number,
296
+};
297
+
298
export type ViewTransitionProps = {
299
name?: string,
300
children?: ReactNodeList,
@@ -302,6 +307,30 @@ export type ViewTransitionProps = {
307
onExit?: (instance: ViewTransitionInstance, types: Array<string>) => void,
308
onShare?: (instance: ViewTransitionInstance, types: Array<string>) => void,
309
onUpdate?: (instance: ViewTransitionInstance, types: Array<string>) => void,
310
+ onGestureEnter?: (
311
+ timeline: GestureProvider,
312
+ options: GestureOptionsRequired,
313
+ instance: ViewTransitionInstance,
314
+ types: Array<string>,
315
+ ) => void,
316
+ onGestureExit?: (
317
+ timeline: GestureProvider,
318
+ options: GestureOptionsRequired,
319
+ instance: ViewTransitionInstance,
320
+ types: Array<string>,
321
+ ) => void,
322
+ onGestureShare?: (
323
+ timeline: GestureProvider,
324
+ options: GestureOptionsRequired,
325
+ instance: ViewTransitionInstance,
326
+ types: Array<string>,
327
+ ) => void,
328
+ onGestureUpdate?: (
329
+ timeline: GestureProvider,
330
+ options: GestureOptionsRequired,
331
+ instance: ViewTransitionInstance,
332
+ types: Array<string>,
333
+ ) => void,
334
};
335
336
export type ActivityProps = {