@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.
Files changed (40) hide show
  1. package/dist/cjs/createAnimations.cjs +559 -299
  2. package/dist/cjs/createAnimations.native.js +653 -318
  3. package/dist/cjs/createAnimations.native.js.map +1 -1
  4. package/dist/cjs/index.cjs +7 -5
  5. package/dist/cjs/index.native.js +21 -13
  6. package/dist/cjs/index.native.js.map +1 -1
  7. package/dist/cjs/polyfill.cjs +3 -1
  8. package/dist/cjs/polyfill.native.js +3 -1
  9. package/dist/cjs/polyfill.native.js.map +1 -1
  10. package/dist/esm/createAnimations.mjs +529 -272
  11. package/dist/esm/createAnimations.mjs.map +1 -1
  12. package/dist/esm/createAnimations.native.js +623 -291
  13. package/dist/esm/createAnimations.native.js.map +1 -1
  14. package/dist/esm/index.js +1 -2
  15. package/dist/esm/index.js.map +1 -6
  16. package/dist/esm/index.mjs +0 -1
  17. package/dist/esm/index.mjs.map +1 -1
  18. package/dist/esm/index.native.js +9 -3
  19. package/dist/esm/index.native.js.map +1 -1
  20. package/dist/esm/polyfill.mjs +3 -1
  21. package/dist/esm/polyfill.mjs.map +1 -1
  22. package/dist/esm/polyfill.native.js +3 -1
  23. package/dist/esm/polyfill.native.js.map +1 -1
  24. package/package.json +9 -12
  25. package/src/createAnimations.tsx +469 -351
  26. package/types/createAnimations.d.ts +1 -0
  27. package/types/createAnimations.d.ts.map +4 -4
  28. package/types/index.d.ts.map +2 -2
  29. package/types/index.native.d.ts.map +2 -2
  30. package/types/polyfill.d.ts.map +2 -2
  31. package/dist/cjs/createAnimations.js +0 -412
  32. package/dist/cjs/createAnimations.js.map +0 -6
  33. package/dist/cjs/index.js +0 -16
  34. package/dist/cjs/index.js.map +0 -6
  35. package/dist/cjs/polyfill.js +0 -2
  36. package/dist/cjs/polyfill.js.map +0 -6
  37. package/dist/esm/createAnimations.js +0 -416
  38. package/dist/esm/createAnimations.js.map +0 -6
  39. package/dist/esm/polyfill.js +0 -2
  40. package/dist/esm/polyfill.js.map +0 -6
@@ -9,7 +9,7 @@ import {
9
9
  hooks,
10
10
  styleToCSS,
11
11
  Text,
12
- type TransitionProp,
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: (cur: number) => Record<string, unknown>
43
- motionValue: MotionValue<number>
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
- // regex to detect non-position transform operations (scale, rotate, skew, matrix, perspective)
58
- // used to identify position-only transforms for the popper animation fix
59
- const nonPositionTransformRe = /scale|rotate|skew|matrix|perspective/
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
- // track if we're still in the initial hydration phase
68
- // TODO didnt realize claude took the wrong one here - this should uust be isComponentHydrating ideally
69
- // but this is fine for beta motion driver rc.0 fix in rc.1
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
- // Track if we just finished entering (transition from entering to not entering)
115
- const wasEnteringRef = useRef(isEntering)
116
- const justFinishedEntering = wasEnteringRef.current && !isEntering
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
- wasEnteringRef.current = isEntering
178
+ refs.current.wasEntering = isEntering
119
179
  })
120
180
 
121
- // Determine animation state for enter/exit transitions
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
- // Disable animation during hydration AND during mounting (should-enter phase)
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 isFirstRender = useRef(true)
134
- const [scope, animate] = useAnimate()
135
- const lastDoAnimate = useRef<Record<string, unknown> | null>(null)
136
- const controls = useRef<AnimationPlaybackControlsWithThen | null>(null)
137
- const styleKey = JSON.stringify(style)
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
- // until fully stable allow debugging in prod to help debugging prod issues
140
- const shouldDebug =
141
- // process.env.NODE_ENV === 'development' &&
142
- props['debug'] && props['debug'] !== 'profile'
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
- } = useMemo(() => {
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
- const lastDontAnimate = useRef<Record<string, unknown> | null>(firstRenderStyle)
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
- disposed.current = true
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 (isFirstRender.current) {
190
- lastDontAnimate.current = null
191
- lastDoAnimate.current = null
268
+ if (refs.current.isFirstRender) {
269
+ refs.current.lastDontAnimate = null
270
+ refs.current.lastDoAnimate = null
192
271
  }
193
272
 
194
- if (shouldDebug) {
195
- console.groupCollapsed(
196
- `[motion] ${debugId} 🌊 animate (${JSON.stringify(getDiff(lastDoAnimate.current, doAnimate), null, 2)})`
197
- )
198
- console.info({
199
- props,
200
- componentState,
201
- doAnimate,
202
- dontAnimate,
203
- animationOptions,
204
- animationProps,
205
- lastDoAnimate: { ...lastDoAnimate.current },
206
- lastDontAnimate: { ...lastDontAnimate.current },
207
- isExiting,
208
- style,
209
- node,
210
- })
211
- console.groupCollapsed(`trace >`)
212
- console.trace()
213
- console.groupEnd()
214
- console.groupEnd()
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
- // we just set it onto animate + set options to not actually animate
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
- // bugfix: going from non-animated to animated in motion -
240
- // motion batches things so the above removal can happen a frame before causing flickering
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
- movedToAnimate[key] = prevDont[key]
250
- // Also update lastDoAnimate to include the previous value
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 = lastDoAnimate.current
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(lastDoAnimate.current, doAnimate)
335
+ const diff = getDiff(refs.current.lastDoAnimate, doAnimate)
336
+
271
337
  if (diff) {
272
- // FIX: Handle animation interruption for position-only animations
273
- // Only apply this fix when:
274
- // 1. There's a running animation
275
- // 2. The transform change is POSITION-ONLY (just translate, no scale/rotate/skew)
276
- // 3. animatePosition is being used (Popper/Tooltip pattern)
277
- // This fixes tooltip position jumping without breaking AnimatePresence scale/rotate animations
278
- // NOTE: We check for animatePosition to avoid this fix causing jitter
279
- // on components like the TAMAGUI logo dot indicator which also use translate-only transforms
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
- // Why check data-popper-animate-position: This attribute is ONLY set on Popper
312
- // elements that explicitly use animatePosition. Regular
313
- // components like the logo Circle don't have this attribute, so they won't
314
- // get this fix applied (which would cause jitter due to getComputedStyle
315
- // overhead on rapid updates).
316
-
317
- // check if this is a Popper element with animated position
318
- const isPopperElement = node.hasAttribute('data-popper-animate-position')
319
-
320
- // also apply fix for AnimatePresence children that just finished entering
321
- // this fixes roving tabs indicator jumping when rapidly switching
322
- const isEnteringPresenceChild = presence && justFinishedEntering
323
-
324
- if (
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
- controls.current = animate(
354
- scope.current,
355
- keyframeDiff,
356
- animationOptions
357
- )
358
- lastAnimateAt.current = Date.now()
359
- lastDontAnimate.current = dontAnimate ? { ...dontAnimate } : {}
360
- lastDoAnimate.current = doAnimate ? { ...doAnimate } : {}
361
- return
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
- // IMPORTANT: Spread to create mutable copy - style objects may be frozen
367
- // fix transparent colors to use rgba for motion.dev compatibility
368
- const fixedDiff = fixTransparentColors({ ...diff }, lastDoAnimate.current)
393
+ const fixedDiff = fixTransparentColors(
394
+ diff,
395
+ refs.current.lastDoAnimate,
396
+ doAnimate
397
+ )
369
398
 
370
- controls.current = animate(scope.current, fixedDiff, animationOptions)
371
- lastAnimateAt.current = Date.now()
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
- // IMPORTANT: Spread to create mutable copies - objects may be frozen
376
- lastDontAnimate.current = dontAnimate ? { ...dontAnimate } : {}
377
- lastDoAnimate.current = doAnimate ? { ...doAnimate } : {}
411
+ refs.current.lastDontAnimate = dontAnimate ? { ...dontAnimate } : {}
412
+ refs.current.lastDoAnimate = doAnimate ? { ...doAnimate } : {}
378
413
  } finally {
379
- if (isExiting) {
380
- if (controls.current) {
381
- controls.current.finished.then(() => {
382
- sendExitComplete?.()
383
- })
384
- } else {
385
- sendExitComplete?.()
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 (isFirstRender.current) {
404
- isFirstRender.current = false
405
-
406
- // during hydration, use full sync logic to prevent flash
407
- // doing this - will fix some of the enter (accordion) glitches but breaks animatepresence
408
- // isHydrating || (isMounting && !isEntering)
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
- const node = stateRef.current.host
411
-
412
- if (node instanceof HTMLElement) {
413
- // IMPORTANT: On first render, we need to:
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
- // IMPORTANT: Spread to create mutable copy - objects may be frozen
442
- lastDontAnimate.current = dontAnimate ? { ...dontAnimate } : {}
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
- lastDontAnimate.current = dontAnimate ? { ...dontAnimate } : {}
449
- lastDoAnimate.current = doAnimate ? { ...doAnimate } : {}
479
+ refs.current.lastDontAnimate = dontAnimate ? { ...dontAnimate } : {}
480
+ refs.current.lastDoAnimate = doAnimate ? { ...doAnimate } : {}
450
481
  return
451
482
  }
452
483
 
453
- // don't ever queue on a render
454
- flushAnimation({
455
- doAnimate,
456
- dontAnimate,
457
- animationOptions,
458
- })
459
- }, [styleKey, isExiting, disableAnimation])
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
- if (shouldDebug) {
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
- console.groupEnd()
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
- } else {
504
- MotionValueStrategy.set(motionValue, config)
552
+ return
553
+ }
505
554
 
506
- if (onFinish) {
507
- const unsubscribe = motionValue.on('change', (value) => {
508
- if (Math.abs(value - next) < 0.01) {
509
- unsubscribe()
510
- onFinish()
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: TransitionProp | null; animateOnly?: string[] },
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(props.transition, animationState)
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
- // Get the effective animation key based on enter/exit/default state
594
- const effectiveKey = getEffectiveAnimation(normalized, animationState)
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
- // If no animation defined, return empty config
597
- if (!effectiveKey && Object.keys(normalized.properties).length === 0) {
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
- // Framer Motion uses seconds, so convert from ms
604
- const delay =
605
- typeof normalized.delay === 'number' ? normalized.delay / 1000 : undefined
700
+ const delay = normalized.delay
606
701
 
607
- // Convert global config overrides from ms to seconds where needed
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
- // apply to default and each per-property config
752
+ convertMsToS(result as ValueTransition)
666
753
  convertMsToS(result.default)
667
754
  for (const key in result) {
668
- if (key !== 'default') convertMsToS(result[key])
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
- // infer type from config shape if not explicitly set, always returns a copy
676
- function withInferredType(config: AnimationConfig): AnimationConfig {
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 || config.type !== 'tween') return
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
- // sort of temporary
710
- const disableAnimationProps = new Set<string>([
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] = useAnimate()
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
- return [
778
- styles.find((x) => x.getStyle) as MotionAnimatedNumberStyle | undefined,
779
- styles.filter((x) => !x.getStyle),
780
- ] as const
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['acceptTagProp'] = true
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 based on previous value
873
- // if previous was rgba, use same rgb with alpha 0, otherwise use rgba(0,0,0,0)
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
- if (typeof prev === 'string') {
884
- // match rgba(r, g, b, a) or rgb(r, g, b)
885
- const rgbaMatch = prev.match(/^rgba?\(([^,]+),\s*([^,]+),\s*([^,)]+)/)
886
- if (rgbaMatch) {
887
- fixed = `rgba(${rgbaMatch[1]}, ${rgbaMatch[2]}, ${rgbaMatch[3]}, 0)`
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) {