@tamagui/animations-motion 2.0.0-rc.8 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/createAnimations.cjs +559 -299
- package/dist/cjs/createAnimations.native.js +653 -318
- package/dist/cjs/createAnimations.native.js.map +1 -1
- package/dist/cjs/index.cjs +7 -5
- package/dist/cjs/index.native.js +21 -13
- package/dist/cjs/index.native.js.map +1 -1
- package/dist/cjs/polyfill.cjs +3 -1
- package/dist/cjs/polyfill.native.js +3 -1
- package/dist/cjs/polyfill.native.js.map +1 -1
- package/dist/esm/createAnimations.mjs +529 -272
- package/dist/esm/createAnimations.mjs.map +1 -1
- package/dist/esm/createAnimations.native.js +623 -291
- package/dist/esm/createAnimations.native.js.map +1 -1
- package/dist/esm/index.js +1 -2
- package/dist/esm/index.js.map +1 -6
- package/dist/esm/index.mjs +0 -1
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/index.native.js +9 -3
- package/dist/esm/index.native.js.map +1 -1
- package/dist/esm/polyfill.mjs +3 -1
- package/dist/esm/polyfill.mjs.map +1 -1
- package/dist/esm/polyfill.native.js +3 -1
- package/dist/esm/polyfill.native.js.map +1 -1
- package/package.json +9 -12
- package/src/createAnimations.tsx +469 -351
- package/types/createAnimations.d.ts +1 -0
- package/types/createAnimations.d.ts.map +4 -4
- package/types/index.d.ts.map +2 -2
- package/types/index.native.d.ts.map +2 -2
- package/types/polyfill.d.ts.map +2 -2
- package/dist/cjs/createAnimations.js +0 -412
- package/dist/cjs/createAnimations.js.map +0 -6
- package/dist/cjs/index.js +0 -16
- package/dist/cjs/index.js.map +0 -6
- package/dist/cjs/polyfill.js +0 -2
- package/dist/cjs/polyfill.js.map +0 -6
- package/dist/esm/createAnimations.js +0 -416
- package/dist/esm/createAnimations.js.map +0 -6
- package/dist/esm/polyfill.js +0 -2
- package/dist/esm/polyfill.js.map +0 -6
package/src/createAnimations.tsx
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
hooks,
|
|
10
10
|
styleToCSS,
|
|
11
11
|
Text,
|
|
12
|
-
|
|
12
|
+
TransitionProp,
|
|
13
13
|
type UniversalAnimatedNumber,
|
|
14
14
|
useComposedRefs,
|
|
15
15
|
useIsomorphicLayoutEffect,
|
|
@@ -28,19 +28,31 @@ import {
|
|
|
28
28
|
import React, {
|
|
29
29
|
forwardRef,
|
|
30
30
|
useEffect,
|
|
31
|
-
useId,
|
|
32
31
|
useLayoutEffect,
|
|
33
32
|
useMemo,
|
|
34
33
|
useRef,
|
|
35
34
|
useState,
|
|
36
35
|
} from 'react'
|
|
37
36
|
|
|
37
|
+
const isServer = typeof window === 'undefined'
|
|
38
|
+
|
|
39
|
+
// SSR-safe wrapper: framer-motion's useAnimate imports its own React copy in
|
|
40
|
+
// Vite SSR bundles which causes "Invalid hook call" errors. during SSR we
|
|
41
|
+
// don't need animations so we return a no-op scope/animate pair.
|
|
42
|
+
function useAnimateSSRSafe() {
|
|
43
|
+
if (isServer) {
|
|
44
|
+
return [useRef(null), (() => {}) as any] as ReturnType<typeof useAnimate>
|
|
45
|
+
}
|
|
46
|
+
return useAnimate()
|
|
47
|
+
}
|
|
48
|
+
|
|
38
49
|
type MotionAnimatedNumber = MotionValue<number>
|
|
39
50
|
type AnimationConfig = ValueTransition
|
|
40
51
|
|
|
41
52
|
type MotionAnimatedNumberStyle = {
|
|
42
|
-
getStyle: (
|
|
43
|
-
motionValue
|
|
53
|
+
getStyle: (...args: any[]) => Record<string, unknown>
|
|
54
|
+
motionValue?: MotionValue<number>
|
|
55
|
+
motionValues?: MotionValue<number>[]
|
|
44
56
|
}
|
|
45
57
|
|
|
46
58
|
/**
|
|
@@ -54,9 +66,26 @@ type TransitionAnimationOptions = AnimationOptions & {
|
|
|
54
66
|
|
|
55
67
|
const MotionValueStrategy = new WeakMap<MotionValue, AnimatedNumberStrategy>()
|
|
56
68
|
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
69
|
+
// pending setValue onFinish callbacks, keyed by motion value. setValue stores
|
|
70
|
+
// the callback here; the change handler in the animated component's useEffect
|
|
71
|
+
// consumes it by chaining to the DOM-level animate() controls so onFinish
|
|
72
|
+
// fires when the *visible* animation actually completes.
|
|
73
|
+
const PendingMotionOnFinish = new WeakMap<MotionValue, () => void>()
|
|
74
|
+
|
|
75
|
+
function settlePendingMotionOnFinish(
|
|
76
|
+
mv: MotionValue,
|
|
77
|
+
controls: AnimationPlaybackControlsWithThen
|
|
78
|
+
) {
|
|
79
|
+
const onFinish = PendingMotionOnFinish.get(mv)
|
|
80
|
+
if (!onFinish) return
|
|
81
|
+
PendingMotionOnFinish.delete(mv)
|
|
82
|
+
// chain to the DOM animation's completion. settle on both resolve and
|
|
83
|
+
// reject — a rejection means the animation was cancelled by a later
|
|
84
|
+
// setValue, and the caller still needs a completion signal. use the
|
|
85
|
+
// real Promise interface (.then().catch()) because framer-motion types
|
|
86
|
+
// the .then() callbacks as VoidFunction with no error arg.
|
|
87
|
+
controls.then(() => onFinish()).catch(() => onFinish())
|
|
88
|
+
}
|
|
60
89
|
|
|
61
90
|
type AnimationProps = {
|
|
62
91
|
doAnimate?: Record<string, unknown>
|
|
@@ -64,9 +93,23 @@ type AnimationProps = {
|
|
|
64
93
|
animationOptions?: AnimationOptions
|
|
65
94
|
}
|
|
66
95
|
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
96
|
+
// internal refs consolidated into a single object
|
|
97
|
+
type MotionRefs = {
|
|
98
|
+
isFirstRender: boolean
|
|
99
|
+
lastDoAnimate: Record<string, unknown> | null
|
|
100
|
+
lastDontAnimate: Record<string, unknown> | null
|
|
101
|
+
controls: AnimationPlaybackControlsWithThen | null
|
|
102
|
+
lastAnimateAt: number
|
|
103
|
+
disposed: boolean
|
|
104
|
+
wasExiting: boolean
|
|
105
|
+
isExiting: boolean
|
|
106
|
+
sendExitComplete: (() => void) | null | undefined
|
|
107
|
+
animationState: 'enter' | 'exit' | 'default'
|
|
108
|
+
frozenExitTarget: Record<string, unknown> | null
|
|
109
|
+
exitCompleteScheduled: boolean
|
|
110
|
+
wasEntering: boolean
|
|
111
|
+
wasDisabled: boolean
|
|
112
|
+
}
|
|
70
113
|
|
|
71
114
|
export function createAnimations<A extends Record<string, AnimationConfig>>(
|
|
72
115
|
animations: A
|
|
@@ -75,14 +118,11 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
|
|
|
75
118
|
const hydratingComponents = new Set<Function>()
|
|
76
119
|
|
|
77
120
|
return {
|
|
78
|
-
// this is only used by Sheet basically for now to pass result of useAnimatedStyle to
|
|
79
121
|
View: MotionView,
|
|
80
122
|
Text: MotionText,
|
|
81
123
|
isReactNative: false,
|
|
82
|
-
supportsCSS: true,
|
|
83
124
|
inputStyle: 'css',
|
|
84
125
|
outputStyle: 'inline',
|
|
85
|
-
needsWebStyles: true,
|
|
86
126
|
avoidReRenders: true,
|
|
87
127
|
animations,
|
|
88
128
|
usePresence,
|
|
@@ -111,58 +151,77 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
|
|
|
111
151
|
const isExiting = presence?.[0] === false
|
|
112
152
|
const sendExitComplete = presence?.[1]
|
|
113
153
|
|
|
114
|
-
//
|
|
115
|
-
const
|
|
116
|
-
|
|
154
|
+
// single consolidated ref with lazy init
|
|
155
|
+
const refs = useRef<MotionRefs>(null!)
|
|
156
|
+
if (!refs.current) {
|
|
157
|
+
refs.current = {
|
|
158
|
+
isFirstRender: true,
|
|
159
|
+
lastDoAnimate: null,
|
|
160
|
+
lastDontAnimate: null,
|
|
161
|
+
controls: null,
|
|
162
|
+
lastAnimateAt: 0,
|
|
163
|
+
disposed: false,
|
|
164
|
+
wasExiting: false,
|
|
165
|
+
isExiting: false,
|
|
166
|
+
sendExitComplete: undefined,
|
|
167
|
+
animationState: 'default',
|
|
168
|
+
frozenExitTarget: null,
|
|
169
|
+
exitCompleteScheduled: false,
|
|
170
|
+
wasEntering: false,
|
|
171
|
+
wasDisabled: false,
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// track entering state transitions
|
|
176
|
+
const justFinishedEntering = refs.current.wasEntering && !isEntering
|
|
117
177
|
useEffect(() => {
|
|
118
|
-
|
|
178
|
+
refs.current.wasEntering = isEntering
|
|
119
179
|
})
|
|
120
180
|
|
|
121
|
-
//
|
|
122
|
-
// Use 'enter' if we're mounting OR if we just finished entering
|
|
181
|
+
// determine animation state for enter/exit transitions
|
|
123
182
|
const animationState: 'enter' | 'exit' | 'default' = isExiting
|
|
124
183
|
? 'exit'
|
|
125
184
|
: isMounting || justFinishedEntering
|
|
126
185
|
? 'enter'
|
|
127
186
|
: 'default'
|
|
128
187
|
|
|
129
|
-
//
|
|
130
|
-
// This prevents the "flying across the page" effect on initial render
|
|
188
|
+
// disable animation during hydration and mounting (prevents "flying across the page")
|
|
131
189
|
const disableAnimation = isComponentHydrating || isMounting || !animationKey
|
|
132
190
|
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
191
|
+
const [scope, animate] = useAnimateSSRSafe()
|
|
192
|
+
|
|
193
|
+
// sync ref values for reliable access from callbacks
|
|
194
|
+
refs.current.isExiting = isExiting
|
|
195
|
+
refs.current.sendExitComplete = sendExitComplete
|
|
196
|
+
refs.current.animationState = animationState
|
|
197
|
+
|
|
198
|
+
// detect transition into exiting state
|
|
199
|
+
const justStartedExiting = isExiting && !refs.current.wasExiting
|
|
200
|
+
const justStoppedExiting = !isExiting && refs.current.wasExiting
|
|
138
201
|
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
202
|
+
// freeze exit animation target so direction changes don't reverse mid-exit
|
|
203
|
+
if (justStartedExiting || justStoppedExiting) {
|
|
204
|
+
refs.current.frozenExitTarget = null
|
|
205
|
+
refs.current.exitCompleteScheduled = false
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// track previous exiting state
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
refs.current.wasExiting = isExiting
|
|
211
|
+
})
|
|
143
212
|
|
|
144
213
|
const {
|
|
145
214
|
dontAnimate = {},
|
|
146
215
|
doAnimate,
|
|
147
216
|
animationOptions,
|
|
148
|
-
} =
|
|
149
|
-
const motionAnimationState = getMotionAnimatedProps(
|
|
150
|
-
props as any,
|
|
151
|
-
style,
|
|
152
|
-
disableAnimation,
|
|
153
|
-
animationState
|
|
154
|
-
)
|
|
155
|
-
return motionAnimationState
|
|
156
|
-
}, [isExiting, animationKey, styleKey, animationState, disableAnimation])
|
|
217
|
+
} = getMotionAnimatedProps(props as any, style, disableAnimation, animationState)
|
|
157
218
|
|
|
158
|
-
const id = useId()
|
|
159
|
-
const debugId = process.env.NODE_ENV === 'development' ? id : ''
|
|
160
|
-
const lastAnimateAt = useRef(0)
|
|
161
|
-
const disposed = useRef(false)
|
|
162
219
|
const [firstRenderStyle] = useState(style)
|
|
163
220
|
|
|
164
221
|
// avoid first render returning wrong styles - always render all, after that we can just mutate
|
|
165
|
-
|
|
222
|
+
if (refs.current.isFirstRender) {
|
|
223
|
+
refs.current.lastDontAnimate = firstRenderStyle
|
|
224
|
+
}
|
|
166
225
|
const [isHydrating, setIsHydrating] = useState(isHydratingGlobal)
|
|
167
226
|
|
|
168
227
|
useLayoutEffect(() => {
|
|
@@ -172,46 +231,68 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
|
|
|
172
231
|
})
|
|
173
232
|
}
|
|
174
233
|
return () => {
|
|
175
|
-
|
|
234
|
+
refs.current.disposed = true
|
|
176
235
|
}
|
|
177
236
|
}, [])
|
|
178
237
|
|
|
179
238
|
const flushAnimation = ({
|
|
180
|
-
doAnimate = {},
|
|
181
|
-
animationOptions = {},
|
|
239
|
+
doAnimate: doAnimateRaw = {},
|
|
240
|
+
animationOptions: passedOptions = {},
|
|
182
241
|
dontAnimate,
|
|
183
242
|
}: AnimationProps) => {
|
|
243
|
+
// track whether THIS flush starts a new animation (vs using stale controls)
|
|
244
|
+
let startedControls: AnimationPlaybackControlsWithThen | null = null
|
|
245
|
+
|
|
246
|
+
// read current state from refs (closure variables can be stale)
|
|
247
|
+
const isCurrentlyExiting = refs.current.isExiting
|
|
248
|
+
const currentSendExitComplete = refs.current.sendExitComplete
|
|
249
|
+
|
|
250
|
+
// freeze exit target: once the first exit animation starts, subsequent
|
|
251
|
+
// renders (e.g. direction change) should not reverse the exit animation.
|
|
252
|
+
let doAnimate = doAnimateRaw
|
|
253
|
+
if (isCurrentlyExiting && refs.current.frozenExitTarget) {
|
|
254
|
+
doAnimate = refs.current.frozenExitTarget
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// only recompute animation options for exit animations to avoid stale state.
|
|
258
|
+
const animationOptions =
|
|
259
|
+
isCurrentlyExiting && currentSendExitComplete
|
|
260
|
+
? getAnimationOptions(props.transition ?? null, 'exit')
|
|
261
|
+
: passedOptions
|
|
262
|
+
|
|
184
263
|
try {
|
|
185
264
|
const node = stateRef.current.host
|
|
186
265
|
|
|
187
266
|
// on first render, reset stale animation refs - they can persist if component
|
|
188
267
|
// instance is reused (e.g. AnimatePresence keepChildrenMounted)
|
|
189
|
-
if (
|
|
190
|
-
|
|
191
|
-
|
|
268
|
+
if (refs.current.isFirstRender) {
|
|
269
|
+
refs.current.lastDontAnimate = null
|
|
270
|
+
refs.current.lastDoAnimate = null
|
|
192
271
|
}
|
|
193
272
|
|
|
194
|
-
if (
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
273
|
+
if (process.env.NODE_ENV === 'development') {
|
|
274
|
+
if (props['debug'] && props['debug'] !== 'profile') {
|
|
275
|
+
console.groupCollapsed(
|
|
276
|
+
`[motion] animate (${JSON.stringify(getDiff(refs.current.lastDoAnimate, doAnimate), null, 2)})`
|
|
277
|
+
)
|
|
278
|
+
console.info({
|
|
279
|
+
props,
|
|
280
|
+
componentState,
|
|
281
|
+
doAnimate,
|
|
282
|
+
dontAnimate,
|
|
283
|
+
animationOptions,
|
|
284
|
+
animationProps,
|
|
285
|
+
lastDoAnimate: { ...refs.current.lastDoAnimate },
|
|
286
|
+
lastDontAnimate: { ...refs.current.lastDontAnimate },
|
|
287
|
+
isExiting,
|
|
288
|
+
style,
|
|
289
|
+
node,
|
|
290
|
+
})
|
|
291
|
+
console.groupCollapsed(`trace >`)
|
|
292
|
+
console.trace()
|
|
293
|
+
console.groupEnd()
|
|
294
|
+
console.groupEnd()
|
|
295
|
+
}
|
|
215
296
|
}
|
|
216
297
|
|
|
217
298
|
if (!(node instanceof HTMLElement)) {
|
|
@@ -219,263 +300,233 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
|
|
|
219
300
|
}
|
|
220
301
|
|
|
221
302
|
// handle case where dontAnimate changes
|
|
222
|
-
|
|
223
|
-
const prevDont = lastDontAnimate.current
|
|
303
|
+
const prevDont = refs.current.lastDontAnimate
|
|
224
304
|
if (dontAnimate) {
|
|
225
305
|
if (prevDont) {
|
|
226
|
-
// Pass doAnimate as preserve to prevent clearing styles that moved to doAnimate
|
|
227
306
|
removeRemovedStyles(prevDont, dontAnimate, node, doAnimate)
|
|
228
307
|
const changed = getDiff(prevDont, dontAnimate)
|
|
229
308
|
if (changed) {
|
|
230
309
|
Object.assign(node.style, changed as any)
|
|
231
310
|
}
|
|
232
311
|
} else {
|
|
233
|
-
// First time - apply directly without diff check
|
|
234
312
|
Object.assign(node.style, dontAnimate as any)
|
|
235
313
|
}
|
|
236
314
|
}
|
|
237
315
|
|
|
238
316
|
if (doAnimate) {
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
// we see this with tooltips, this is not an ideal solution though, ideally we can remove/update
|
|
242
|
-
// in the same batch/frame as motion
|
|
243
|
-
// Also sync motion's internal state for properties moving from dontAnimate to doAnimate
|
|
317
|
+
// when a property moves from dontAnimate to doAnimate, preserve
|
|
318
|
+
// the current inline style value so WAAPI starts from the right place
|
|
244
319
|
if (prevDont) {
|
|
245
|
-
const movedToAnimate: Record<string, unknown> = {}
|
|
246
320
|
for (const key in prevDont) {
|
|
247
321
|
if (key in doAnimate) {
|
|
248
322
|
node.style[key] = prevDont[key]
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
// This prevents animating from undefined to the current value
|
|
252
|
-
// when a property transitions from dontAnimate to doAnimate
|
|
253
|
-
if (lastDoAnimate.current) {
|
|
254
|
-
lastDoAnimate.current[key] = prevDont[key]
|
|
323
|
+
if (refs.current.lastDoAnimate) {
|
|
324
|
+
refs.current.lastDoAnimate[key] = prevDont[key]
|
|
255
325
|
}
|
|
256
326
|
}
|
|
257
327
|
}
|
|
258
|
-
// Sync motion's internal state for moved properties
|
|
259
|
-
if (Object.keys(movedToAnimate).length > 0) {
|
|
260
|
-
animate(scope.current, { ...movedToAnimate }, { duration: 0 })
|
|
261
|
-
}
|
|
262
328
|
}
|
|
263
329
|
|
|
264
|
-
const lastAnimated =
|
|
330
|
+
const lastAnimated = refs.current.lastDoAnimate
|
|
265
331
|
if (lastAnimated) {
|
|
266
|
-
// Pass dontAnimate as third arg to prevent clearing styles that moved to dontAnimate
|
|
267
332
|
removeRemovedStyles(lastAnimated, doAnimate, node, dontAnimate)
|
|
268
333
|
}
|
|
269
334
|
|
|
270
|
-
const diff = getDiff(
|
|
335
|
+
const diff = getDiff(refs.current.lastDoAnimate, doAnimate)
|
|
336
|
+
|
|
271
337
|
if (diff) {
|
|
272
|
-
//
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
|
|
281
|
-
const isRunning =
|
|
282
|
-
/**
|
|
283
|
-
* TypeError: Cannot read properties of undefined (reading 'state')
|
|
284
|
-
* at GroupAnimationWithThen.getAll (http://localhost:8081/node_modules/.vite/deps/@tamagui_config_v5-motion.js?v=d717d926:2374:30)
|
|
285
|
-
* at get state (http://localhost:8081/node_modules/.vite/deps/@tamagui_config_v5-motion.js?v=d717d926:2403:17)
|
|
286
|
-
* at flushAnimation (http://localhost:8081/node_modules/.vite/deps/@tamagui_config_v5-motion.js?v=d717d926:9686:49)
|
|
287
|
-
**/
|
|
288
|
-
// @ts-expect-error it is there, and for some crazy reason in ~/chat pretty often i get errors ^
|
|
289
|
-
controls.current?.animations?.length === 0
|
|
290
|
-
? false
|
|
291
|
-
: controls.current?.state === 'running'
|
|
292
|
-
const targetTransform =
|
|
293
|
-
typeof diff.transform === 'string' ? diff.transform : null
|
|
294
|
-
|
|
295
|
-
// only apply position fix for translate-only transforms
|
|
296
|
-
const isPositionOnlyTransform =
|
|
297
|
-
targetTransform &&
|
|
298
|
-
targetTransform.includes('translate') &&
|
|
299
|
-
!nonPositionTransformRe.test(targetTransform)
|
|
300
|
-
|
|
301
|
-
// Position fix for Popper/Tooltip elements with animatePosition.
|
|
302
|
-
// Only apply when:
|
|
303
|
-
// 1. Animation is actively running
|
|
304
|
-
// 2. Transform is position-only (translate without scale/rotate/etc)
|
|
305
|
-
// 3. Element has data-popper-animate-position attribute (set by Popper when
|
|
306
|
-
// animatePosition is true)
|
|
307
|
-
//
|
|
308
|
-
// The issue: when a Popper animation is interrupted mid-flight, motion's
|
|
309
|
-
// animate() may start from wrong position causing jumps to origin.
|
|
338
|
+
// capture frozen exit target on first exit diff
|
|
339
|
+
if (isCurrentlyExiting && !refs.current.frozenExitTarget) {
|
|
340
|
+
refs.current.frozenExitTarget = { ...doAnimate }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// capture mid-flight values so we can provide explicit [from, to]
|
|
344
|
+
// keyframes to WAAPI, ensuring smooth interpolation from the
|
|
345
|
+
// current visual state.
|
|
310
346
|
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
isRunning &&
|
|
326
|
-
controls.current &&
|
|
327
|
-
isPositionOnlyTransform &&
|
|
328
|
-
(isPopperElement || isEnteringPresenceChild)
|
|
329
|
-
) {
|
|
330
|
-
const currentTransform = getComputedStyle(node).transform
|
|
331
|
-
|
|
332
|
-
if (currentTransform && currentTransform !== 'none') {
|
|
333
|
-
const matrixMatch = currentTransform.match(
|
|
334
|
-
/matrix\([^,]+,\s*[^,]+,\s*[^,]+,\s*[^,]+,\s*([^,]+),\s*([^)]+)\)/
|
|
335
|
-
)
|
|
336
|
-
|
|
337
|
-
if (matrixMatch) {
|
|
338
|
-
// stop animation and preserve current position
|
|
339
|
-
controls.current.stop()
|
|
340
|
-
node.style.transform = currentTransform
|
|
341
|
-
|
|
342
|
-
// animate from current matrix position to target
|
|
343
|
-
const currentX = Number.parseFloat(matrixMatch[1])
|
|
344
|
-
const currentY = Number.parseFloat(matrixMatch[2])
|
|
345
|
-
const keyframeDiff = {
|
|
346
|
-
...diff,
|
|
347
|
-
transform: [
|
|
348
|
-
`translateX(${currentX}px) translateY(${currentY}px)`,
|
|
349
|
-
targetTransform,
|
|
350
|
-
],
|
|
347
|
+
// only stop() during exit — for non-exit cases, WAAPI
|
|
348
|
+
// naturally replaces only conflicting property animations,
|
|
349
|
+
// letting non-conflicting ones (like an in-flight enter
|
|
350
|
+
// opacity animation) continue to completion.
|
|
351
|
+
const isPopperPosition = node.hasAttribute('data-popper-animate-position')
|
|
352
|
+
let midFlightValues: Record<string, string> | null = null
|
|
353
|
+
if (refs.current.controls) {
|
|
354
|
+
try {
|
|
355
|
+
const computed = getComputedStyle(node)
|
|
356
|
+
midFlightValues = {}
|
|
357
|
+
for (const key in diff) {
|
|
358
|
+
const val = (computed as any)[key]
|
|
359
|
+
if (val !== undefined && val !== '') {
|
|
360
|
+
midFlightValues[key] = val
|
|
351
361
|
}
|
|
362
|
+
}
|
|
363
|
+
} catch {
|
|
364
|
+
// getComputedStyle can fail on detached nodes
|
|
365
|
+
}
|
|
352
366
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
367
|
+
if (isCurrentlyExiting) {
|
|
368
|
+
refs.current.controls.stop()
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// write mid-flight values to inline so the 1-frame gap
|
|
372
|
+
// (while motion resolves keyframes) shows the correct
|
|
373
|
+
// position instead of stale inline styles
|
|
374
|
+
if (midFlightValues) {
|
|
375
|
+
for (const key in midFlightValues) {
|
|
376
|
+
;(node.style as any)[key] = midFlightValues[key]
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// for popper position elements, cancel WAAPI animations
|
|
381
|
+
// directly so motion.dev's internal stop() sees "idle" state
|
|
382
|
+
// and skips commitStyles. without this, commitStyles writes
|
|
383
|
+
// a mid-flight transform that's visible for 1 frame before
|
|
384
|
+
// the new animation starts, causing a flash toward (0,0).
|
|
385
|
+
if (isPopperPosition) {
|
|
386
|
+
const anims = node.getAnimations()
|
|
387
|
+
for (const anim of anims) {
|
|
388
|
+
anim.cancel()
|
|
362
389
|
}
|
|
363
390
|
}
|
|
364
391
|
}
|
|
365
392
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
393
|
+
const fixedDiff = fixTransparentColors(
|
|
394
|
+
diff,
|
|
395
|
+
refs.current.lastDoAnimate,
|
|
396
|
+
doAnimate
|
|
397
|
+
)
|
|
369
398
|
|
|
370
|
-
|
|
371
|
-
|
|
399
|
+
// provide explicit [from, to] keyframe for transforms during
|
|
400
|
+
// mid-flight interruption so motion starts from the right place
|
|
401
|
+
if (midFlightValues?.transform && fixedDiff.transform) {
|
|
402
|
+
fixedDiff.transform = [midFlightValues.transform, fixedDiff.transform]
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
startedControls = animate(scope.current, fixedDiff, animationOptions)
|
|
406
|
+
refs.current.controls = startedControls
|
|
407
|
+
refs.current.lastAnimateAt = Date.now()
|
|
372
408
|
}
|
|
373
409
|
}
|
|
374
410
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
lastDoAnimate.current = doAnimate ? { ...doAnimate } : {}
|
|
411
|
+
refs.current.lastDontAnimate = dontAnimate ? { ...dontAnimate } : {}
|
|
412
|
+
refs.current.lastDoAnimate = doAnimate ? { ...doAnimate } : {}
|
|
378
413
|
} finally {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
414
|
+
// exit completion: notify AnimatePresence when exit animation finishes
|
|
415
|
+
if (isCurrentlyExiting && currentSendExitComplete) {
|
|
416
|
+
if (startedControls) {
|
|
417
|
+
// new animation started — attach completion handler
|
|
418
|
+
refs.current.exitCompleteScheduled = true
|
|
419
|
+
startedControls.finished
|
|
420
|
+
.then(() => {
|
|
421
|
+
// guard: only complete if still exiting (prevents stale promise
|
|
422
|
+
// from calling sendExitComplete after a re-entry cancels the exit)
|
|
423
|
+
if (refs.current.isExiting) {
|
|
424
|
+
currentSendExitComplete()
|
|
425
|
+
}
|
|
426
|
+
})
|
|
427
|
+
.catch(() => {
|
|
428
|
+
if (refs.current.isExiting) {
|
|
429
|
+
currentSendExitComplete()
|
|
430
|
+
}
|
|
431
|
+
})
|
|
432
|
+
} else if (!refs.current.exitCompleteScheduled) {
|
|
433
|
+
// no animation started AND none previously scheduled (e.g. diff=null
|
|
434
|
+
// on re-render mid-exit because frozenExitTarget matches lastDoAnimate)
|
|
435
|
+
// — complete immediately only if we've never started an exit animation
|
|
436
|
+
currentSendExitComplete()
|
|
386
437
|
}
|
|
438
|
+
// else: exit animation already scheduled via a previous flush,
|
|
439
|
+
// its .finished promise will call sendExitComplete when done
|
|
387
440
|
}
|
|
388
441
|
}
|
|
389
442
|
}
|
|
390
443
|
|
|
391
|
-
useStyleEmitter?.((nextStyle) => {
|
|
444
|
+
useStyleEmitter?.((nextStyle, effectiveTransition) => {
|
|
392
445
|
const animationProps = getMotionAnimatedProps(
|
|
393
446
|
props as any,
|
|
394
447
|
nextStyle,
|
|
395
448
|
disableAnimation,
|
|
396
|
-
animationState
|
|
449
|
+
refs.current.animationState,
|
|
450
|
+
effectiveTransition
|
|
397
451
|
)
|
|
398
452
|
|
|
399
453
|
flushAnimation(animationProps)
|
|
400
454
|
})
|
|
401
455
|
|
|
402
456
|
useIsomorphicLayoutEffect(() => {
|
|
403
|
-
if (
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
//
|
|
408
|
-
//
|
|
457
|
+
if (refs.current.isFirstRender) {
|
|
458
|
+
refs.current.isFirstRender = false
|
|
459
|
+
refs.current.wasDisabled = disableAnimation
|
|
460
|
+
|
|
461
|
+
// during hydration, skip inline style writes entirely — SSR CSS
|
|
462
|
+
// already has the correct values. writing them again as inline
|
|
463
|
+
// styles triggers browser style recalc that causes visible font
|
|
464
|
+
// flashes (fontWeight, fontSize, letterSpacing, lineHeight).
|
|
465
|
+
// we only need to track refs for future animation diffing.
|
|
409
466
|
if (isHydrating) {
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
// 1. Apply dontAnimate styles to the DOM (enterStyle values like scale(0))
|
|
415
|
-
// 2. Tell motion about these styles so it knows the starting state
|
|
416
|
-
// This ensures AnimatePresence enter animations work correctly.
|
|
417
|
-
|
|
418
|
-
if (dontAnimate) {
|
|
419
|
-
// Apply initial styles to DOM
|
|
420
|
-
Object.assign(node.style, dontAnimate as any)
|
|
421
|
-
|
|
422
|
-
// Tell motion about the initial state by animating instantly to dontAnimate
|
|
423
|
-
// This syncs motion's internal state with what's actually on the DOM
|
|
424
|
-
// IMPORTANT: Spread to create mutable copy - React/Tamagui style objects may be frozen
|
|
425
|
-
animate(scope.current, { ...dontAnimate }, { duration: 0 })
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
// If there are styles to animate, set them up (but animation is disabled on first render)
|
|
429
|
-
if (doAnimate && Object.keys(doAnimate).length > 0) {
|
|
430
|
-
// IMPORTANT: Spread to create mutable copy - objects may be frozen
|
|
431
|
-
lastDoAnimate.current = { ...doAnimate }
|
|
432
|
-
animate(scope.current, { ...doAnimate }, { duration: 0 })
|
|
433
|
-
} else {
|
|
434
|
-
// doAnimate is empty, so track dontAnimate as the initial animated state
|
|
435
|
-
// This way on next render, getDiff will detect the change
|
|
436
|
-
// IMPORTANT: Spread to create mutable copy - objects may be frozen
|
|
437
|
-
lastDoAnimate.current = dontAnimate ? { ...dontAnimate } : {}
|
|
438
|
-
}
|
|
467
|
+
if (doAnimate && Object.keys(doAnimate).length > 0) {
|
|
468
|
+
refs.current.lastDoAnimate = { ...doAnimate }
|
|
469
|
+
} else {
|
|
470
|
+
refs.current.lastDoAnimate = dontAnimate ? { ...dontAnimate } : {}
|
|
439
471
|
}
|
|
440
472
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
lastAnimateAt.current = Date.now()
|
|
473
|
+
refs.current.lastDontAnimate = dontAnimate ? { ...dontAnimate } : {}
|
|
474
|
+
refs.current.lastAnimateAt = Date.now()
|
|
444
475
|
return
|
|
445
476
|
}
|
|
446
477
|
|
|
447
478
|
// after hydration, use simpler logic
|
|
448
|
-
|
|
449
|
-
|
|
479
|
+
refs.current.lastDontAnimate = dontAnimate ? { ...dontAnimate } : {}
|
|
480
|
+
refs.current.lastDoAnimate = doAnimate ? { ...doAnimate } : {}
|
|
450
481
|
return
|
|
451
482
|
}
|
|
452
483
|
|
|
453
|
-
//
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
484
|
+
// when animations first turn on after the mount/hydration handoff, the
|
|
485
|
+
// element is already at its resting position (SSR atomic class, or the
|
|
486
|
+
// dontAnimate inline styles). animating now would spring from the lost
|
|
487
|
+
// "from" value — which for a transform reads as 0 and flashes the
|
|
488
|
+
// element across the screen (e.g. progress bar flashing full, #4011).
|
|
489
|
+
// jump straight to the resolved styles instead, so it renders at the
|
|
490
|
+
// right place with no enter animation. only real changes after this
|
|
491
|
+
// animate. components with an explicit enter animation still animate.
|
|
492
|
+
const justEnabled = refs.current.wasDisabled && !disableAnimation
|
|
493
|
+
refs.current.wasDisabled = disableAnimation
|
|
494
|
+
if (justEnabled && animationState !== 'enter') {
|
|
495
|
+
const node = stateRef.current.host
|
|
496
|
+
if (node instanceof HTMLElement) {
|
|
497
|
+
if (dontAnimate) Object.assign(node.style, dontAnimate)
|
|
498
|
+
if (doAnimate) Object.assign(node.style, doAnimate)
|
|
499
|
+
}
|
|
500
|
+
refs.current.lastDontAnimate = dontAnimate ? { ...dontAnimate } : {}
|
|
501
|
+
refs.current.lastDoAnimate = doAnimate ? { ...doAnimate } : {}
|
|
502
|
+
return
|
|
503
|
+
}
|
|
460
504
|
|
|
461
|
-
|
|
462
|
-
console.groupCollapsed(`[motion] 🌊 render`)
|
|
463
|
-
console.info({
|
|
464
|
-
style,
|
|
505
|
+
flushAnimation({
|
|
465
506
|
doAnimate,
|
|
466
507
|
dontAnimate,
|
|
467
|
-
styleKey,
|
|
468
|
-
scope,
|
|
469
508
|
animationOptions,
|
|
470
|
-
isExiting,
|
|
471
|
-
isFirstRender: isFirstRender.current,
|
|
472
|
-
animationProps,
|
|
473
509
|
})
|
|
474
|
-
|
|
510
|
+
}, [style, isExiting, disableAnimation])
|
|
511
|
+
|
|
512
|
+
if (process.env.NODE_ENV === 'development') {
|
|
513
|
+
if (props['debug'] && props['debug'] !== 'profile') {
|
|
514
|
+
console.groupCollapsed(`[motion] render`)
|
|
515
|
+
console.info({
|
|
516
|
+
style,
|
|
517
|
+
doAnimate,
|
|
518
|
+
dontAnimate,
|
|
519
|
+
scope,
|
|
520
|
+
animationOptions,
|
|
521
|
+
isExiting,
|
|
522
|
+
isFirstRender: refs.current.isFirstRender,
|
|
523
|
+
animationProps,
|
|
524
|
+
})
|
|
525
|
+
console.groupEnd()
|
|
526
|
+
}
|
|
475
527
|
}
|
|
476
528
|
|
|
477
529
|
return {
|
|
478
|
-
// we never change this, after first render on
|
|
479
530
|
style: firstRenderStyle,
|
|
480
531
|
ref: scope,
|
|
481
532
|
render: 'div',
|
|
@@ -495,26 +546,44 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
|
|
|
495
546
|
},
|
|
496
547
|
setValue(next, config = { type: 'spring' }, onFinish) {
|
|
497
548
|
if (config.type === 'direct') {
|
|
498
|
-
MotionValueStrategy.set(motionValue, {
|
|
499
|
-
type: 'direct',
|
|
500
|
-
})
|
|
549
|
+
MotionValueStrategy.set(motionValue, { type: 'direct' })
|
|
501
550
|
motionValue.set(next)
|
|
502
551
|
onFinish?.()
|
|
503
|
-
|
|
504
|
-
|
|
552
|
+
return
|
|
553
|
+
}
|
|
505
554
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
555
|
+
MotionValueStrategy.set(motionValue, config)
|
|
556
|
+
|
|
557
|
+
// we intentionally DO NOT animate the motion value itself here
|
|
558
|
+
// (via framer-motion's imperative animate(motionValue, next)).
|
|
559
|
+
// doing so drives the JS value over time, which fires a 'change'
|
|
560
|
+
// event per frame, and each change event kicks off a new DOM
|
|
561
|
+
// animate(node, ...) that cancels the previous one — the DOM
|
|
562
|
+
// never reaches the target (double-animation stall).
|
|
563
|
+
//
|
|
564
|
+
// instead we jump the motion value to `next` synchronously. the
|
|
565
|
+
// animated component's change handler receives a single change
|
|
566
|
+
// event, computes the final webStyle, and drives the visible
|
|
567
|
+
// animation via DOM animate(node, webStyle, springConfig). that
|
|
568
|
+
// DOM animation is the real timing source.
|
|
569
|
+
//
|
|
570
|
+
// to make `onFinish` resolve when the VISIBLE animation finishes
|
|
571
|
+
// (not synchronously on the change event), we stash it in
|
|
572
|
+
// PendingMotionOnFinish here and the change handler chains it to
|
|
573
|
+
// the DOM animate() controls.
|
|
574
|
+
if (onFinish) {
|
|
575
|
+
// if a previous setValue is still pending on this motion value,
|
|
576
|
+
// fire it now — the new setValue will cancel the prior DOM
|
|
577
|
+
// animation, and the caller is still owed a completion signal.
|
|
578
|
+
const prior = PendingMotionOnFinish.get(motionValue)
|
|
579
|
+
if (prior) {
|
|
580
|
+
PendingMotionOnFinish.delete(motionValue)
|
|
581
|
+
prior()
|
|
513
582
|
}
|
|
514
|
-
|
|
515
|
-
motionValue.set(next)
|
|
516
|
-
// Motion doesn't have a direct onFinish callback, so we simulate it
|
|
583
|
+
PendingMotionOnFinish.set(motionValue, onFinish)
|
|
517
584
|
}
|
|
585
|
+
|
|
586
|
+
motionValue.set(next)
|
|
518
587
|
},
|
|
519
588
|
stop() {
|
|
520
589
|
motionValue.stop()
|
|
@@ -536,7 +605,6 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
|
|
|
536
605
|
// we need to change useAnimatedNumberStyle to have dep args to be concurrent safe
|
|
537
606
|
getStyleRef.current = getStyleProp
|
|
538
607
|
|
|
539
|
-
// never changes
|
|
540
608
|
return useMemo(() => {
|
|
541
609
|
return {
|
|
542
610
|
getStyle: (cur) => {
|
|
@@ -546,13 +614,27 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
|
|
|
546
614
|
} satisfies MotionAnimatedNumberStyle
|
|
547
615
|
}, [])
|
|
548
616
|
},
|
|
617
|
+
|
|
618
|
+
useAnimatedNumbersStyle(vals, getStyleProp) {
|
|
619
|
+
const motionValues = vals.map((v) => v.getInstance() as MotionValue<number>)
|
|
620
|
+
const getStyleRef = useRef<typeof getStyleProp>(getStyleProp)
|
|
621
|
+
getStyleRef.current = getStyleProp
|
|
622
|
+
|
|
623
|
+
return useMemo(() => {
|
|
624
|
+
return {
|
|
625
|
+
getStyle: (...currentValues: number[]) => getStyleRef.current(...currentValues),
|
|
626
|
+
motionValues,
|
|
627
|
+
} satisfies MotionAnimatedNumberStyle
|
|
628
|
+
}, [])
|
|
629
|
+
},
|
|
549
630
|
}
|
|
550
631
|
|
|
551
632
|
function getMotionAnimatedProps(
|
|
552
|
-
props: { transition
|
|
633
|
+
props: { transition?: TransitionProp | null; animateOnly?: string[] },
|
|
553
634
|
style: Record<string, unknown>,
|
|
554
635
|
disable: boolean,
|
|
555
|
-
animationState: 'enter' | 'exit' | 'default' = 'default'
|
|
636
|
+
animationState: 'enter' | 'exit' | 'default' = 'default',
|
|
637
|
+
transitionOverride?: TransitionProp | null
|
|
556
638
|
): AnimationProps {
|
|
557
639
|
if (disable) {
|
|
558
640
|
return {
|
|
@@ -560,7 +642,10 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
|
|
|
560
642
|
}
|
|
561
643
|
}
|
|
562
644
|
|
|
563
|
-
const animationOptions = getAnimationOptions(
|
|
645
|
+
const animationOptions = getAnimationOptions(
|
|
646
|
+
transitionOverride ?? props.transition ?? null,
|
|
647
|
+
animationState
|
|
648
|
+
)
|
|
564
649
|
|
|
565
650
|
let dontAnimate: Record<string, unknown> | undefined
|
|
566
651
|
let doAnimate: Record<string, unknown> | undefined
|
|
@@ -590,50 +675,54 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
|
|
|
590
675
|
): TransitionAnimationOptions {
|
|
591
676
|
const normalized = normalizeTransition(transitionProp)
|
|
592
677
|
|
|
593
|
-
|
|
594
|
-
|
|
678
|
+
let effectiveKey = getEffectiveAnimation(normalized, animationState)
|
|
679
|
+
|
|
680
|
+
// fallback: if we have enter/exit defined but state is 'default' and no default key,
|
|
681
|
+
// use enter timing as fallback to avoid empty animation options
|
|
682
|
+
if (!effectiveKey && animationState === 'default') {
|
|
683
|
+
effectiveKey = normalized.enter || normalized.exit || null
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const globalConfigOverride: Record<string, unknown> | undefined = normalized.config
|
|
687
|
+
? { ...normalized.config }
|
|
688
|
+
: undefined
|
|
595
689
|
|
|
596
|
-
|
|
597
|
-
|
|
690
|
+
if (
|
|
691
|
+
!effectiveKey &&
|
|
692
|
+
Object.keys(normalized.properties).length === 0 &&
|
|
693
|
+
!globalConfigOverride
|
|
694
|
+
) {
|
|
598
695
|
return {}
|
|
599
696
|
}
|
|
600
697
|
|
|
601
698
|
const defaultConfig = effectiveKey ? withInferredType(animations[effectiveKey]) : null
|
|
602
699
|
|
|
603
|
-
|
|
604
|
-
const delay =
|
|
605
|
-
typeof normalized.delay === 'number' ? normalized.delay / 1000 : undefined
|
|
700
|
+
const delay = normalized.delay
|
|
606
701
|
|
|
607
|
-
//
|
|
608
|
-
let globalConfigOverride: Record<string, unknown> | undefined
|
|
609
|
-
if (normalized.config) {
|
|
610
|
-
globalConfigOverride = { ...normalized.config }
|
|
611
|
-
if (typeof normalized.config.duration === 'number') {
|
|
612
|
-
globalConfigOverride.duration = normalized.config.duration / 1000
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
// Build the animation options
|
|
617
|
-
// Framer Motion's animate() expects default config at the TOP LEVEL, not nested under 'default'
|
|
618
|
-
// Per-property configs can be nested under property names like { scale: { duration: 1 } }
|
|
702
|
+
// framer motion's animate() expects default config at the TOP LEVEL
|
|
619
703
|
const result: TransitionAnimationOptions = {}
|
|
620
704
|
|
|
621
|
-
// Apply base config from preset at top level (this is what animate() reads as default)
|
|
622
705
|
if (defaultConfig) {
|
|
623
706
|
Object.assign(result, defaultConfig)
|
|
624
707
|
}
|
|
625
708
|
|
|
626
|
-
// Apply global spring config overrides at top level
|
|
627
709
|
if (globalConfigOverride) {
|
|
628
710
|
Object.assign(result, globalConfigOverride)
|
|
711
|
+
if (
|
|
712
|
+
result.type === undefined &&
|
|
713
|
+
result.duration !== undefined &&
|
|
714
|
+
result.damping === undefined &&
|
|
715
|
+
result.stiffness === undefined &&
|
|
716
|
+
result.mass === undefined
|
|
717
|
+
) {
|
|
718
|
+
result.type = 'tween'
|
|
719
|
+
}
|
|
629
720
|
}
|
|
630
721
|
|
|
631
|
-
// Apply delay at top level
|
|
632
722
|
if (delay) {
|
|
633
723
|
result.delay = delay
|
|
634
724
|
}
|
|
635
725
|
|
|
636
|
-
// Also set the 'default' key for backwards compatibility with per-property fallback logic
|
|
637
726
|
if (defaultConfig || globalConfigOverride || delay) {
|
|
638
727
|
result.default = {
|
|
639
728
|
...defaultConfig,
|
|
@@ -642,7 +731,6 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
|
|
|
642
731
|
}
|
|
643
732
|
}
|
|
644
733
|
|
|
645
|
-
// Add property-specific animations
|
|
646
734
|
for (const [propName, animationNameOrConfig] of Object.entries(
|
|
647
735
|
normalized.properties
|
|
648
736
|
)) {
|
|
@@ -653,27 +741,30 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
|
|
|
653
741
|
? withInferredType(animations[animationNameOrConfig.type])
|
|
654
742
|
: defaultConfig
|
|
655
743
|
|
|
656
|
-
// @ts-expect-error
|
|
657
744
|
result[propName] = {
|
|
658
745
|
...baseConfig,
|
|
659
746
|
...animationNameOrConfig,
|
|
660
|
-
}
|
|
747
|
+
} as ValueTransition
|
|
661
748
|
}
|
|
662
749
|
}
|
|
663
750
|
|
|
664
751
|
// we standardize to ms across drivers, motion expects s
|
|
665
|
-
|
|
752
|
+
convertMsToS(result as ValueTransition)
|
|
666
753
|
convertMsToS(result.default)
|
|
667
754
|
for (const key in result) {
|
|
668
|
-
if (key !== 'default'
|
|
755
|
+
if (key !== 'default' && typeof result[key] === 'object') {
|
|
756
|
+
convertMsToS(result[key])
|
|
757
|
+
}
|
|
669
758
|
}
|
|
670
759
|
|
|
671
760
|
return result
|
|
672
761
|
}
|
|
673
762
|
}
|
|
674
763
|
|
|
675
|
-
|
|
676
|
-
|
|
764
|
+
function withInferredType(config: AnimationConfig | undefined): AnimationConfig {
|
|
765
|
+
if (!config) {
|
|
766
|
+
return { type: 'spring' }
|
|
767
|
+
}
|
|
677
768
|
const isTimingBased =
|
|
678
769
|
config.duration !== undefined &&
|
|
679
770
|
config.damping === undefined &&
|
|
@@ -682,11 +773,20 @@ function withInferredType(config: AnimationConfig): AnimationConfig {
|
|
|
682
773
|
return { type: isTimingBased ? 'tween' : 'spring', ...config }
|
|
683
774
|
}
|
|
684
775
|
|
|
685
|
-
// convert tween duration/delay from ms to s (motion expects seconds)
|
|
686
776
|
function convertMsToS(config: ValueTransition | undefined) {
|
|
687
|
-
if (!config
|
|
688
|
-
if (typeof config.duration === 'number') config.duration = config.duration / 1000
|
|
777
|
+
if (!config) return
|
|
689
778
|
if (typeof config.delay === 'number') config.delay = config.delay / 1000
|
|
779
|
+
if (typeof config.duration === 'number') {
|
|
780
|
+
const isTimingBased =
|
|
781
|
+
config.type === 'tween' ||
|
|
782
|
+
(config.type === undefined &&
|
|
783
|
+
config.damping === undefined &&
|
|
784
|
+
config.stiffness === undefined &&
|
|
785
|
+
config.mass === undefined)
|
|
786
|
+
if (isTimingBased) {
|
|
787
|
+
config.duration = config.duration / 1000
|
|
788
|
+
}
|
|
789
|
+
}
|
|
690
790
|
}
|
|
691
791
|
|
|
692
792
|
function removeRemovedStyles(
|
|
@@ -697,7 +797,6 @@ function removeRemovedStyles(
|
|
|
697
797
|
) {
|
|
698
798
|
for (const key in prev) {
|
|
699
799
|
if (!(key in next)) {
|
|
700
|
-
// Don't clear if the style is now in dontAnimate (moved from animated to non-animated)
|
|
701
800
|
if (dontClearIfIn && key in dontClearIfIn) {
|
|
702
801
|
continue
|
|
703
802
|
}
|
|
@@ -706,56 +805,37 @@ function removeRemovedStyles(
|
|
|
706
805
|
}
|
|
707
806
|
}
|
|
708
807
|
|
|
709
|
-
//
|
|
710
|
-
|
|
808
|
+
// truly non-animatable CSS properties (discrete, keyword-based, no interpolation)
|
|
809
|
+
// properties like margin, maxHeight, zIndex, etc are animatable and intentionally excluded
|
|
810
|
+
export const disableAnimationProps: Set<string> = new Set<string>([
|
|
711
811
|
'alignContent',
|
|
712
812
|
'alignItems',
|
|
713
|
-
'aspectRatio',
|
|
714
|
-
'backdropFilter',
|
|
715
813
|
'boxSizing',
|
|
716
814
|
'contain',
|
|
717
815
|
'containerType',
|
|
718
816
|
'display',
|
|
719
817
|
'flexBasis',
|
|
720
818
|
'flexDirection',
|
|
721
|
-
'flexGrow',
|
|
722
|
-
'flexShrink',
|
|
723
819
|
'fontFamily',
|
|
724
820
|
'justifyContent',
|
|
725
|
-
'marginBottom',
|
|
726
|
-
'marginLeft',
|
|
727
|
-
'marginRight',
|
|
728
|
-
'marginTop',
|
|
729
|
-
'maxHeight',
|
|
730
|
-
'maxWidth',
|
|
731
|
-
'minHeight',
|
|
732
|
-
'minWidth',
|
|
733
821
|
'overflow',
|
|
734
822
|
'overflowX',
|
|
735
823
|
'overflowY',
|
|
736
824
|
'pointerEvents',
|
|
737
825
|
'position',
|
|
738
826
|
'textWrap',
|
|
739
|
-
'transformOrigin',
|
|
740
827
|
'userSelect',
|
|
741
|
-
'WebkitBackdropFilter',
|
|
742
|
-
'zIndex',
|
|
743
828
|
])
|
|
744
829
|
|
|
745
830
|
const MotionView = createMotionView('div')
|
|
746
831
|
const MotionText = createMotionView('span')
|
|
747
832
|
|
|
748
833
|
function createMotionView(defaultTag: string) {
|
|
749
|
-
// return forwardRef((props: any, ref) => {
|
|
750
|
-
// console.info('rendering?', props)
|
|
751
|
-
// const Element = motion[props.render || defaultTag]
|
|
752
|
-
// return <Element ref={ref} {...props} />
|
|
753
|
-
// })
|
|
754
834
|
const isText = defaultTag === 'span'
|
|
755
835
|
|
|
756
836
|
const Component = forwardRef((propsIn: any, ref) => {
|
|
757
837
|
const { forwardedRef, animation, render = defaultTag, style, ...propsRest } = propsIn
|
|
758
|
-
const [scope, animate] =
|
|
838
|
+
const [scope, animate] = useAnimateSSRSafe()
|
|
759
839
|
const hostRef = useRef<HTMLElement>(null)
|
|
760
840
|
const composedRefs = useComposedRefs(forwardedRef, ref, hostRef, scope)
|
|
761
841
|
|
|
@@ -772,12 +852,17 @@ function createMotionView(defaultTag: string) {
|
|
|
772
852
|
|
|
773
853
|
const styles = Array.isArray(style) ? style : [style]
|
|
774
854
|
|
|
775
|
-
// we can assume just one animatedStyle max for now
|
|
776
855
|
const [animatedStyle, nonAnimatedStyles] = (() => {
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
856
|
+
let animatedStyle: MotionAnimatedNumberStyle | undefined
|
|
857
|
+
const nonAnimatedStyles: typeof styles = []
|
|
858
|
+
for (const style of styles) {
|
|
859
|
+
if (style.getStyle) {
|
|
860
|
+
animatedStyle = style as MotionAnimatedNumberStyle
|
|
861
|
+
} else {
|
|
862
|
+
nonAnimatedStyles.push(style)
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
return [animatedStyle, nonAnimatedStyles] as const
|
|
781
866
|
})()
|
|
782
867
|
|
|
783
868
|
function getProps(props: any) {
|
|
@@ -792,7 +877,6 @@ function createMotionView(defaultTag: string) {
|
|
|
792
877
|
{
|
|
793
878
|
isAnimated: false,
|
|
794
879
|
noClass: true,
|
|
795
|
-
// noMergeStyle: true,
|
|
796
880
|
resolveValues: 'auto',
|
|
797
881
|
}
|
|
798
882
|
)
|
|
@@ -801,7 +885,6 @@ function createMotionView(defaultTag: string) {
|
|
|
801
885
|
return {}
|
|
802
886
|
}
|
|
803
887
|
|
|
804
|
-
// we can definitely get rid of this here
|
|
805
888
|
if (out.viewProps.style) {
|
|
806
889
|
fixStyles(out.viewProps.style)
|
|
807
890
|
styleToCSS(out.viewProps.style)
|
|
@@ -817,9 +900,40 @@ function createMotionView(defaultTag: string) {
|
|
|
817
900
|
useEffect(() => {
|
|
818
901
|
if (!animatedStyle) return
|
|
819
902
|
|
|
903
|
+
// multi-value path: subscribe to all motion values
|
|
904
|
+
if (animatedStyle.motionValues) {
|
|
905
|
+
const mvs = animatedStyle.motionValues
|
|
906
|
+
const unsubs = mvs.map((mv) =>
|
|
907
|
+
mv.on('change', () => {
|
|
908
|
+
const currentValues = mvs.map((v) => v.get())
|
|
909
|
+
const nextStyle = animatedStyle.getStyle(...currentValues)
|
|
910
|
+
const animationConfig = MotionValueStrategy.get(mv)
|
|
911
|
+
const node = hostRef.current
|
|
912
|
+
|
|
913
|
+
const webStyle = getProps({ style: nextStyle }).style
|
|
914
|
+
|
|
915
|
+
if (webStyle && node instanceof HTMLElement) {
|
|
916
|
+
const motionAnimationConfig =
|
|
917
|
+
animationConfig?.type === 'timing'
|
|
918
|
+
? { type: 'tween', duration: (animationConfig?.duration || 0) / 1000 }
|
|
919
|
+
: animationConfig?.type === 'direct'
|
|
920
|
+
? { type: 'tween', duration: 0 }
|
|
921
|
+
: { type: 'spring', ...(animationConfig as any) }
|
|
922
|
+
|
|
923
|
+
const controls = animate(node, webStyle as any, motionAnimationConfig)
|
|
924
|
+
settlePendingMotionOnFinish(mv, controls)
|
|
925
|
+
}
|
|
926
|
+
})
|
|
927
|
+
)
|
|
928
|
+
return () => unsubs.forEach((fn) => fn())
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// single-value path
|
|
932
|
+
if (!animatedStyle.motionValue) return
|
|
933
|
+
|
|
820
934
|
return animatedStyle.motionValue.on('change', (value) => {
|
|
821
935
|
const nextStyle = animatedStyle.getStyle(value)
|
|
822
|
-
const animationConfig = MotionValueStrategy.get(animatedStyle.motionValue)
|
|
936
|
+
const animationConfig = MotionValueStrategy.get(animatedStyle.motionValue!)
|
|
823
937
|
const node = hostRef.current
|
|
824
938
|
|
|
825
939
|
const webStyle = getProps({ style: nextStyle }).style
|
|
@@ -838,7 +952,8 @@ function createMotionView(defaultTag: string) {
|
|
|
838
952
|
...(animationConfig as any),
|
|
839
953
|
}
|
|
840
954
|
|
|
841
|
-
animate(node, webStyle as any, motionAnimationConfig)
|
|
955
|
+
const controls = animate(node, webStyle as any, motionAnimationConfig)
|
|
956
|
+
settlePendingMotionOnFinish(animatedStyle.motionValue!, controls)
|
|
842
957
|
}
|
|
843
958
|
})
|
|
844
959
|
}, [animatedStyle])
|
|
@@ -846,7 +961,7 @@ function createMotionView(defaultTag: string) {
|
|
|
846
961
|
return <Element {...transformedProps} ref={composedRefs} />
|
|
847
962
|
})
|
|
848
963
|
|
|
849
|
-
Component['
|
|
964
|
+
Component['acceptRenderProp'] = true
|
|
850
965
|
|
|
851
966
|
return Component
|
|
852
967
|
}
|
|
@@ -869,22 +984,25 @@ function getDiff<T extends Record<string, unknown>>(
|
|
|
869
984
|
return diff
|
|
870
985
|
}
|
|
871
986
|
|
|
872
|
-
// motion.dev can't animate to "transparent" - convert it to rgba
|
|
873
|
-
//
|
|
987
|
+
// motion.dev can't animate to "transparent" - convert it to rgba
|
|
988
|
+
// try to extract RGB from previous or next value for smooth color transitions
|
|
874
989
|
function fixTransparentColors(
|
|
875
990
|
diff: Record<string, unknown>,
|
|
876
|
-
previous: Record<string, unknown> | null
|
|
991
|
+
previous: Record<string, unknown> | null,
|
|
992
|
+
next?: Record<string, unknown> | null
|
|
877
993
|
): Record<string, unknown> {
|
|
878
994
|
let result = diff
|
|
879
995
|
for (const key in diff) {
|
|
880
996
|
if (diff[key] === 'transparent') {
|
|
881
|
-
const prev = previous?.[key]
|
|
882
997
|
let fixed = 'rgba(0, 0, 0, 0)'
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
998
|
+
const candidates = [previous?.[key], next?.[key]]
|
|
999
|
+
for (const candidate of candidates) {
|
|
1000
|
+
if (typeof candidate === 'string' && candidate !== 'transparent') {
|
|
1001
|
+
const rgbaMatch = candidate.match(/^rgba?\(([^,]+),\s*([^,]+),\s*([^,)]+)/)
|
|
1002
|
+
if (rgbaMatch) {
|
|
1003
|
+
fixed = `rgba(${rgbaMatch[1]}, ${rgbaMatch[2]}, ${rgbaMatch[3]}, 0)`
|
|
1004
|
+
break
|
|
1005
|
+
}
|
|
888
1006
|
}
|
|
889
1007
|
}
|
|
890
1008
|
if (result === diff) {
|