Add ScrollTimeline Polyfill for Swipe Recognizer using a new CustomTimeline protocol (#33501)
The React API is just that we now accept this protocol as an alternative to a native `AnimationTimeline` to be passed to `startGestureTransition`. This is specifically the DOM version. ```js interface CustomTimeline { currentTime: number; animate(animation: Animation): void | (() => void); } ``` Instead, of passing this to the `Animation` that we start to control the View Transition keyframes, we instead inverse the control and pass the `Animation` to this one. It lets any custom implementation drive the updates. It can do so by updating the time every frame or letting it run a time based animation (such as momentum scroll). In this case I added a basic polyfill for `ScrollTimeline` in the example but we'll need a better one.
Sebastian Markbåge committed
Jul 2, 2025 at 16:07 UTC
fc41c24aa6e674319aed5bd0b25ca6fb92c268df
12 files changed
+155
-43
.eslintrc.js
+1
@@ -622,6 +622,7 @@ module.exports = {
622
ScrollTimeline: 'readonly',
623
EventListenerOptionsOrUseCapture: 'readonly',
624
FocusOptions: 'readonly',
625
+ OptionalEffectTiming: 'readonly',
626
627
spyOnDev: 'readonly',
628
spyOnDevAndProd: 'readonly',
fixtures/view-transition/loader/package.json
new
+3
@@ -0,0 +1,3 @@
1
+{
2
+ "type": "module"
3
+}
fixtures/view-transition/loader/server.js
new
+54
@@ -0,0 +1,54 @@
1
+import babel from '@babel/core';
2
+
3
+const babelOptions = {
4
+ babelrc: false,
5
+ ignore: [/\/(build|node_modules)\//],
6
+ plugins: [
7
+ '@babel/plugin-syntax-import-meta',
8
+ '@babel/plugin-transform-react-jsx',
9
+ ],
10
+};
11
+
12
+export async function load(url, context, defaultLoad) {
13
+ if (url.endsWith('.css')) {
14
+ return {source: 'export default {}', format: 'module', shortCircuit: true};
15
+ }
16
+ const {format} = context;
17
+ const result = await defaultLoad(url, context, defaultLoad);
18
+ if (result.format === 'module') {
19
+ const opt = Object.assign({filename: url}, babelOptions);
20
+ const newResult = await babel.transformAsync(result.source, opt);
21
+ if (!newResult) {
22
+ if (typeof result.source === 'string') {
23
+ return result;
24
+ }
25
+ return {
26
+ source: Buffer.from(result.source).toString('utf8'),
27
+ format: 'module',
28
+ };
29
+ }
30
+ return {source: newResult.code, format: 'module'};
31
+ }
32
+ return defaultLoad(url, context, defaultLoad);
33
+}
34
+
35
+async function babelTransformSource(source, context, defaultTransformSource) {
36
+ const {format} = context;
37
+ if (format === 'module') {
38
+ const opt = Object.assign({filename: context.url}, babelOptions);
39
+ const newResult = await babel.transformAsync(source, opt);
40
+ if (!newResult) {
41
+ if (typeof source === 'string') {
42
+ return {source};
43
+ }
44
+ return {
45
+ source: Buffer.from(source).toString('utf8'),
46
+ };
47
+ }
48
+ return {source: newResult.code};
49
+ }
50
+ return defaultTransformSource(source, context, defaultTransformSource);
51
+}
52
+
53
+export const transformSource =
54
+ process.version < 'v16' ? babelTransformSource : undefined;
fixtures/view-transition/package.json
+4
-3
@@ -13,7 +13,8 @@
13
"express": "^4.14.0",
14
"ignore-styles": "^5.0.1",
15
"react": "^19.0.0",
16
- "react-dom": "^19.0.0"
16
+ "react-dom": "^19.0.0",
17
+ "animation-timelines": "^0.0.4"
18
},
19
"eslintConfig": {
20
"extends": [
@@ -27,8 +28,8 @@
28
"prebuild": "cp -r ../../build/oss-experimental/* ./node_modules/ && rm -rf node_modules/.cache;",
29
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
30
"dev:client": "BROWSER=none PORT=3001 react-scripts start",
30
- "dev:server": "NODE_ENV=development node server",
31
- "start": "react-scripts build && NODE_ENV=production node server",
31
+ "dev:server": "NODE_ENV=development node --experimental-loader ./loader/server.js server",
32
+ "start": "react-scripts build && NODE_ENV=production node --experimental-loader ./loader/server.js server",
33
"build": "react-scripts build",
34
"test": "react-scripts test --env=jsdom",
35
"eject": "react-scripts eject"
fixtures/view-transition/server/index.js
+7
-5
@@ -20,13 +20,15 @@ if (process.env.NODE_ENV === 'development') {
20
for (var key in require.cache) {
21
delete require.cache[key];
22
}
23
- const render = require('./render').default;
24
- render(req.url, res);
23
+ import('./render.js').then(({default: render}) => {
24
+ render(req.url, res);
25
+ });
26
});
27
} else {
27
- const render = require('./render').default;
28
- app.get('/', function (req, res) {
29
- render(req.url, res);
28
+ import('./render.js').then(({default: render}) => {
29
+ app.get('/', function (req, res) {
30
+ render(req.url, res);
31
+ });
32
});
33
}
34
fixtures/view-transition/server/render.js
+1
-1
@@ -1,7 +1,7 @@
1
import React from 'react';
2
import {renderToPipeableStream} from 'react-dom/server';
3
4
-import App from '../src/components/App';
4
+import App from '../src/components/App.js';
5
6
let assets;
7
if (process.env.NODE_ENV === 'development') {
fixtures/view-transition/src/components/App.js
+2
-2
@@ -6,8 +6,8 @@ import React, {
6
unstable_addTransitionType as addTransitionType,
7
} from 'react';
8
9
-import Chrome from './Chrome';
10
-import Page from './Page';
9
+import Chrome from './Chrome.js';
10
+import Page from './Page.js';
11
12
const enableNavigationAPI = typeof navigation === 'object';
13
fixtures/view-transition/src/components/Page.js
+2
-2
@@ -13,12 +13,12 @@ import React, {
13
14
import {createPortal} from 'react-dom';
15
16
-import SwipeRecognizer from './SwipeRecognizer';
16
+import SwipeRecognizer from './SwipeRecognizer.js';
17
18
import './Page.css';
19
20
import transitions from './Transitions.module.css';
21
-import NestedReveal from './NestedReveal';
21
+import NestedReveal from './NestedReveal.js';
22
23
async function sleep(ms) {
24
return new Promise(resolve => setTimeout(resolve, ms));
fixtures/view-transition/src/components/SwipeRecognizer.js
+15
-7
@@ -5,6 +5,8 @@ import React, {
5
unstable_startGestureTransition as startGestureTransition,
6
} from 'react';
7
8
+import ScrollTimelinePolyfill from 'animation-timelines/scroll-timeline';
9
+
10
// Example of a Component that can recognize swipe gestures using a ScrollTimeline
11
// without scrolling its own content. Allowing it to be used as an inert gesture
12
// recognizer to drive a View Transition.
@@ -25,14 +27,20 @@ export default function SwipeRecognizer({
27
if (activeGesture.current !== null) {
28
return;
29
}
28
- if (typeof ScrollTimeline !== 'function') {
29
- return;
30
+
31
+ let scrollTimeline;
32
+ if (typeof ScrollTimeline === 'function') {
33
+ // eslint-disable-next-line no-undef
34
+ scrollTimeline = new ScrollTimeline({
35
+ source: scrollRef.current,
36
+ axis: axis,
37
+ });
38
+ } else {
39
+ scrollTimeline = new ScrollTimelinePolyfill({
40
+ source: scrollRef.current,
41
+ axis: axis,
42
+ });
43
}
31
- // eslint-disable-next-line no-undef
32
- const scrollTimeline = new ScrollTimeline({
33
- source: scrollRef.current,
34
- axis: axis,
35
- });
44
activeGesture.current = startGestureTransition(
45
scrollTimeline,
46
() => {
fixtures/view-transition/src/index.js
+1
-1
@@ -1,7 +1,7 @@
1
import React from 'react';
2
import {hydrateRoot} from 'react-dom/client';
3
4
-import App from './components/App';
4
+import App from './components/App.js';
5
6
hydrateRoot(
7
document,
fixtures/view-transition/yarn.lock
+5
@@ -2427,6 +2427,11 @@ ajv@^8.0.0, ajv@^8.6.0, ajv@^8.9.0:
2427
json-schema-traverse "^1.0.0"
2428
require-from-string "^2.0.2"
2429
2430
+animation-timelines@^0.0.4:
2431
+ version "0.0.4"
2432
+ resolved "https://registry.yarnpkg.com/animation-timelines/-/animation-timelines-0.0.4.tgz#7ac4614bae73c4d1ea2ff18d5d87a518793258af"
2433
+ integrity sha512-HwCE3m1nM8ZdLbwDwD1j5ZNKmY+3J2CliXJNIsf3y1Si927SIaWpfxkycTg5nWLJSHgjsYxrmOy2Jbo4JR1e9A==
2434
+
2435
ansi-escapes@^4.2.1, ansi-escapes@^4.3.1:
2436
version "4.3.2"
2437
resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e"
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+60
-22
@@ -2213,7 +2213,8 @@ function animateGesture(
2213
keyframes: any,
2214
targetElement: Element,
2215
pseudoElement: string,
2216
- timeline: AnimationTimeline,
2216
+ timeline: GestureTimeline,
2217
+ customTimelineCleanup: Array<() => void>,
2218
rangeStart: number,
2219
rangeEnd: number,
2220
moveFirstFrameIntoViewport: boolean,
@@ -2274,24 +2275,49 @@ function animateGesture(
2275
}
2276
// TODO: Reverse the reverse if the original direction is reverse.
2277
const reverse = rangeStart > rangeEnd;
2277
- targetElement.animate(keyframes, {
2278
- pseudoElement: pseudoElement,
2279
- // Set the timeline to the current gesture timeline to drive the updates.
2280
- timeline: timeline,
2281
- // We reset all easing functions to linear so that it feels like you
2282
- // have direct impact on the transition and to avoid double bouncing
2283
- // from scroll bouncing.
2284
- easing: 'linear',
2285
- // We fill in both direction for overscroll.
2286
- fill: 'both', // TODO: Should we preserve the fill instead?
2287
- // We play all gestures in reverse, except if we're in reverse direction
2288
- // in which case we need to play it in reverse of the reverse.
2289
- direction: reverse ? 'normal' : 'reverse',
2290
- // Range start needs to be higher than range end. If it goes in reverse
2291
- // we reverse the whole animation below.
2292
- rangeStart: (reverse ? rangeEnd : rangeStart) + '%',
2293
- rangeEnd: (reverse ? rangeStart : rangeEnd) + '%',
2294
- });
2278
+ if (timeline instanceof AnimationTimeline) {
2279
+ // Native Timeline
2280
+ targetElement.animate(keyframes, {
2281
+ pseudoElement: pseudoElement,
2282
+ // Set the timeline to the current gesture timeline to drive the updates.
2283
+ timeline: timeline,
2284
+ // We reset all easing functions to linear so that it feels like you
2285
+ // have direct impact on the transition and to avoid double bouncing
2286
+ // from scroll bouncing.
2287
+ easing: 'linear',
2288
+ // We fill in both direction for overscroll.
2289
+ fill: 'both', // TODO: Should we preserve the fill instead?
2290
+ // We play all gestures in reverse, except if we're in reverse direction
2291
+ // in which case we need to play it in reverse of the reverse.
2292
+ direction: reverse ? 'normal' : 'reverse',
2293
+ // Range start needs to be higher than range end. If it goes in reverse
2294
+ // we reverse the whole animation below.
2295
+ rangeStart: (reverse ? rangeEnd : rangeStart) + '%',
2296
+ rangeEnd: (reverse ? rangeStart : rangeEnd) + '%',
2297
+ });
2298
+ } else {
2299
+ // Custom Timeline
2300
+ const animation = targetElement.animate(keyframes, {
2301
+ pseudoElement: pseudoElement,
2302
+ // We reset all easing functions to linear so that it feels like you
2303
+ // have direct impact on the transition and to avoid double bouncing
2304
+ // from scroll bouncing.
2305
+ easing: 'linear',
2306
+ // We fill in both direction for overscroll.
2307
+ fill: 'both', // TODO: Should we preserve the fill instead?
2308
+ // We play all gestures in reverse, except if we're in reverse direction
2309
+ // in which case we need to play it in reverse of the reverse.
2310
+ direction: reverse ? 'normal' : 'reverse',
2311
+ // We set the delay and duration to represent the span of the range.
2312
+ delay: reverse ? rangeEnd : rangeStart,
2313
+ duration: reverse ? rangeStart - rangeEnd : rangeEnd - rangeStart,
2314
+ });
2315
+ // Let the custom timeline take control of driving the animation.
2316
+ const cleanup = timeline.animate(animation);
2317
+ if (cleanup) {
2318
+ customTimelineCleanup.push(cleanup);
2319
+ }
2320
+ }
2321
}
2322
2323
export function startGestureTransition(
@@ -2320,6 +2346,7 @@ export function startGestureTransition(
2346
});
2347
// $FlowFixMe[prop-missing]
2348
ownerDocument.__reactViewTransition = transition;
2349
+ const customTimelineCleanup: Array<() => void> = []; // Cleanup Animations started in a CustomTimeline
2350
const readyCallback = () => {
2351
const documentElement: Element = (ownerDocument.documentElement: any);
2352
// Loop through all View Transition Animations.
@@ -2419,6 +2446,7 @@ export function startGestureTransition(
2446
effect.target,
2447
pseudoElement,
2448
timeline,
2449
+ customTimelineCleanup,
2450
adjustedRangeStart,
2451
adjustedRangeEnd,
2452
isGeneratedGroupAnim,
@@ -2445,6 +2473,7 @@ export function startGestureTransition(
2473
effect.target,
2474
pseudoElementName,
2475
timeline,
2476
+ customTimelineCleanup,
2477
rangeStart,
2478
rangeEnd,
2479
false,
@@ -2494,6 +2523,10 @@ export function startGestureTransition(
2523
transition.ready.then(readyForAnimations, handleError);
2524
transition.finished.finally(() => {
2525
cancelAllViewTransitionAnimations((ownerDocument.documentElement: any));
2526
+ for (let i = 0; i < customTimelineCleanup.length; i++) {
2527
+ const cleanup = customTimelineCleanup[i];
2528
+ cleanup();
2529
+ }
2530
// $FlowFixMe[prop-missing]
2531
if (ownerDocument.__reactViewTransition === transition) {
2532
// $FlowFixMe[prop-missing]
@@ -2597,10 +2630,15 @@ export function createViewTransitionInstance(
2630
};
2631
}
2632
2600
-export type GestureTimeline = AnimationTimeline; // TODO: More provider types.
2633
+interface CustomTimeline {
2634
+ currentTime: number;
2635
+ animate(animation: Animation): void | (() => void);
2636
+}
2637
+
2638
+export type GestureTimeline = AnimationTimeline | CustomTimeline;
2639
2602
-export function getCurrentGestureOffset(provider: GestureTimeline): number {
2603
- const time = provider.currentTime;
2640
+export function getCurrentGestureOffset(timeline: GestureTimeline): number {
2641
+ const time = timeline.currentTime;
2642
if (time === null) {
2643
throw new Error(
2644
'Cannot start a gesture with a disconnected AnimationTimeline.',