@hanzogui/animations-motion 2.0.0-rc.41-hanzoai.5

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 (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +72 -0
  3. package/dist/cjs/createAnimations.cjs +698 -0
  4. package/dist/cjs/createAnimations.native.js +822 -0
  5. package/dist/cjs/createAnimations.native.js.map +1 -0
  6. package/dist/cjs/index.cjs +21 -0
  7. package/dist/cjs/index.native.js +45 -0
  8. package/dist/cjs/index.native.js.map +1 -0
  9. package/dist/cjs/polyfill.cjs +3 -0
  10. package/dist/cjs/polyfill.native.js +6 -0
  11. package/dist/cjs/polyfill.native.js.map +1 -0
  12. package/dist/esm/createAnimations.mjs +661 -0
  13. package/dist/esm/createAnimations.mjs.map +1 -0
  14. package/dist/esm/createAnimations.native.js +782 -0
  15. package/dist/esm/createAnimations.native.js.map +1 -0
  16. package/dist/esm/index.js +2 -0
  17. package/dist/esm/index.js.map +1 -0
  18. package/dist/esm/index.mjs +2 -0
  19. package/dist/esm/index.mjs.map +1 -0
  20. package/dist/esm/index.native.js +17 -0
  21. package/dist/esm/index.native.js.map +1 -0
  22. package/dist/esm/polyfill.mjs +4 -0
  23. package/dist/esm/polyfill.mjs.map +1 -0
  24. package/dist/esm/polyfill.native.js +4 -0
  25. package/dist/esm/polyfill.native.js.map +1 -0
  26. package/package.json +53 -0
  27. package/src/createAnimations.tsx +991 -0
  28. package/src/index.native.ts +28 -0
  29. package/src/index.ts +3 -0
  30. package/src/polyfill.ts +4 -0
  31. package/types/createAnimations.d.ts +8 -0
  32. package/types/createAnimations.d.ts.map +11 -0
  33. package/types/index.d.ts +4 -0
  34. package/types/index.d.ts.map +11 -0
  35. package/types/index.native.d.ts +4 -0
  36. package/types/index.native.d.ts.map +11 -0
  37. package/types/polyfill.d.ts +2 -0
  38. package/types/polyfill.d.ts.map +11 -0
@@ -0,0 +1,991 @@
1
+ import { getEffectiveAnimation, normalizeTransition } from '@hanzogui/animation-helpers'
2
+ import { ResetPresence, usePresence } from '@hanzogui/use-presence'
3
+ import {
4
+ type AnimatedNumberStrategy,
5
+ type AnimationDriver,
6
+ fixStyles,
7
+ getConfig,
8
+ getSplitStyles,
9
+ hooks,
10
+ styleToCSS,
11
+ Text,
12
+ TransitionProp,
13
+ type UniversalAnimatedNumber,
14
+ useComposedRefs,
15
+ useIsomorphicLayoutEffect,
16
+ useThemeWithState,
17
+ View,
18
+ } from '@hanzogui/web'
19
+ import {
20
+ type AnimationOptions,
21
+ type AnimationPlaybackControlsWithThen,
22
+ type MotionValue,
23
+ useAnimate,
24
+ useMotionValue,
25
+ useMotionValueEvent,
26
+ type ValueTransition,
27
+ } from 'motion/react'
28
+ import React, {
29
+ forwardRef,
30
+ useEffect,
31
+ useLayoutEffect,
32
+ useMemo,
33
+ useRef,
34
+ useState,
35
+ } from 'react'
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
+
49
+ type MotionAnimatedNumber = MotionValue<number>
50
+ type AnimationConfig = ValueTransition
51
+
52
+ type MotionAnimatedNumberStyle = {
53
+ getStyle: (...args: any[]) => Record<string, unknown>
54
+ motionValue?: MotionValue<number>
55
+ motionValues?: MotionValue<number>[]
56
+ }
57
+
58
+ /**
59
+ * Animation options with optional default and per-property configs.
60
+ * This extends AnimationOptions to support the default key.
61
+ */
62
+ type TransitionAnimationOptions = AnimationOptions & {
63
+ default?: ValueTransition
64
+ [propertyName: string]: ValueTransition | undefined
65
+ }
66
+
67
+ const MotionValueStrategy = new WeakMap<MotionValue, AnimatedNumberStrategy>()
68
+
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
+ }
89
+
90
+ type AnimationProps = {
91
+ doAnimate?: Record<string, unknown>
92
+ dontAnimate?: Record<string, unknown>
93
+ animationOptions?: AnimationOptions
94
+ }
95
+
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
+ }
112
+
113
+ export function createAnimations<A extends Record<string, AnimationConfig>>(
114
+ animations: A
115
+ ): AnimationDriver<A> {
116
+ let isHydratingGlobal: boolean | undefined
117
+ const hydratingComponents = new Set<Function>()
118
+
119
+ return {
120
+ View: MotionView,
121
+ Text: MotionText,
122
+ isReactNative: false,
123
+ inputStyle: 'css',
124
+ outputStyle: 'inline',
125
+ avoidReRenders: true,
126
+ animations,
127
+ usePresence,
128
+ ResetPresence,
129
+
130
+ onMount() {
131
+ isHydratingGlobal = false
132
+ hydratingComponents.forEach((cb) => cb())
133
+ },
134
+
135
+ useAnimations: (animationProps) => {
136
+ if (isHydratingGlobal === undefined && !getConfig().settings.disableSSR) {
137
+ isHydratingGlobal = true
138
+ }
139
+
140
+ const { props, style, componentState, stateRef, useStyleEmitter, presence } =
141
+ animationProps
142
+
143
+ const animationKey = Array.isArray(props.transition)
144
+ ? props.transition[0]
145
+ : props.transition
146
+
147
+ const isComponentHydrating = componentState.unmounted === true
148
+ const isMounting = componentState.unmounted === 'should-enter'
149
+ const isEntering = !!componentState.unmounted
150
+ const isExiting = presence?.[0] === false
151
+ const sendExitComplete = presence?.[1]
152
+
153
+ // single consolidated ref with lazy init
154
+ const refs = useRef<MotionRefs>(null!)
155
+ if (!refs.current) {
156
+ refs.current = {
157
+ isFirstRender: true,
158
+ lastDoAnimate: null,
159
+ lastDontAnimate: null,
160
+ controls: null,
161
+ lastAnimateAt: 0,
162
+ disposed: false,
163
+ wasExiting: false,
164
+ isExiting: false,
165
+ sendExitComplete: undefined,
166
+ animationState: 'default',
167
+ frozenExitTarget: null,
168
+ exitCompleteScheduled: false,
169
+ wasEntering: false,
170
+ }
171
+ }
172
+
173
+ // track entering state transitions
174
+ const justFinishedEntering = refs.current.wasEntering && !isEntering
175
+ useEffect(() => {
176
+ refs.current.wasEntering = isEntering
177
+ })
178
+
179
+ // determine animation state for enter/exit transitions
180
+ const animationState: 'enter' | 'exit' | 'default' = isExiting
181
+ ? 'exit'
182
+ : isMounting || justFinishedEntering
183
+ ? 'enter'
184
+ : 'default'
185
+
186
+ // disable animation during hydration and mounting (prevents "flying across the page")
187
+ const disableAnimation = isComponentHydrating || isMounting || !animationKey
188
+
189
+ const [scope, animate] = useAnimateSSRSafe()
190
+
191
+ // sync ref values for reliable access from callbacks
192
+ refs.current.isExiting = isExiting
193
+ refs.current.sendExitComplete = sendExitComplete
194
+ refs.current.animationState = animationState
195
+
196
+ // detect transition into exiting state
197
+ const justStartedExiting = isExiting && !refs.current.wasExiting
198
+ const justStoppedExiting = !isExiting && refs.current.wasExiting
199
+
200
+ // freeze exit animation target so direction changes don't reverse mid-exit
201
+ if (justStartedExiting || justStoppedExiting) {
202
+ refs.current.frozenExitTarget = null
203
+ refs.current.exitCompleteScheduled = false
204
+ }
205
+
206
+ // track previous exiting state
207
+ useEffect(() => {
208
+ refs.current.wasExiting = isExiting
209
+ })
210
+
211
+ const {
212
+ dontAnimate = {},
213
+ doAnimate,
214
+ animationOptions,
215
+ } = getMotionAnimatedProps(props as any, style, disableAnimation, animationState)
216
+
217
+ const [firstRenderStyle] = useState(style)
218
+
219
+ // avoid first render returning wrong styles - always render all, after that we can just mutate
220
+ if (refs.current.isFirstRender) {
221
+ refs.current.lastDontAnimate = firstRenderStyle
222
+ }
223
+ const [isHydrating, setIsHydrating] = useState(isHydratingGlobal)
224
+
225
+ useLayoutEffect(() => {
226
+ if (isHydratingGlobal) {
227
+ hydratingComponents.add(() => {
228
+ setIsHydrating(false)
229
+ })
230
+ }
231
+ return () => {
232
+ refs.current.disposed = true
233
+ }
234
+ }, [])
235
+
236
+ const flushAnimation = ({
237
+ doAnimate: doAnimateRaw = {},
238
+ animationOptions: passedOptions = {},
239
+ dontAnimate,
240
+ }: AnimationProps) => {
241
+ // track whether THIS flush starts a new animation (vs using stale controls)
242
+ let startedControls: AnimationPlaybackControlsWithThen | null = null
243
+
244
+ // read current state from refs (closure variables can be stale)
245
+ const isCurrentlyExiting = refs.current.isExiting
246
+ const currentSendExitComplete = refs.current.sendExitComplete
247
+
248
+ // freeze exit target: once the first exit animation starts, subsequent
249
+ // renders (e.g. direction change) should not reverse the exit animation.
250
+ let doAnimate = doAnimateRaw
251
+ if (isCurrentlyExiting && refs.current.frozenExitTarget) {
252
+ doAnimate = refs.current.frozenExitTarget
253
+ }
254
+
255
+ // only recompute animation options for exit animations to avoid stale state.
256
+ const animationOptions =
257
+ isCurrentlyExiting && currentSendExitComplete
258
+ ? getAnimationOptions(props.transition ?? null, 'exit')
259
+ : passedOptions
260
+
261
+ try {
262
+ const node = stateRef.current.host
263
+
264
+ // on first render, reset stale animation refs - they can persist if component
265
+ // instance is reused (e.g. AnimatePresence keepChildrenMounted)
266
+ if (refs.current.isFirstRender) {
267
+ refs.current.lastDontAnimate = null
268
+ refs.current.lastDoAnimate = null
269
+ }
270
+
271
+ if (process.env.NODE_ENV === 'development') {
272
+ if (props['debug'] && props['debug'] !== 'profile') {
273
+ console.groupCollapsed(
274
+ `[motion] animate (${JSON.stringify(getDiff(refs.current.lastDoAnimate, doAnimate), null, 2)})`
275
+ )
276
+ console.info({
277
+ props,
278
+ componentState,
279
+ doAnimate,
280
+ dontAnimate,
281
+ animationOptions,
282
+ animationProps,
283
+ lastDoAnimate: { ...refs.current.lastDoAnimate },
284
+ lastDontAnimate: { ...refs.current.lastDontAnimate },
285
+ isExiting,
286
+ style,
287
+ node,
288
+ })
289
+ console.groupCollapsed(`trace >`)
290
+ console.trace()
291
+ console.groupEnd()
292
+ console.groupEnd()
293
+ }
294
+ }
295
+
296
+ if (!(node instanceof HTMLElement)) {
297
+ return
298
+ }
299
+
300
+ // handle case where dontAnimate changes
301
+ const prevDont = refs.current.lastDontAnimate
302
+ if (dontAnimate) {
303
+ if (prevDont) {
304
+ removeRemovedStyles(prevDont, dontAnimate, node, doAnimate)
305
+ const changed = getDiff(prevDont, dontAnimate)
306
+ if (changed) {
307
+ Object.assign(node.style, changed as any)
308
+ }
309
+ } else {
310
+ Object.assign(node.style, dontAnimate as any)
311
+ }
312
+ }
313
+
314
+ if (doAnimate) {
315
+ // when a property moves from dontAnimate to doAnimate, preserve
316
+ // the current inline style value so WAAPI starts from the right place
317
+ if (prevDont) {
318
+ for (const key in prevDont) {
319
+ if (key in doAnimate) {
320
+ node.style[key] = prevDont[key]
321
+ if (refs.current.lastDoAnimate) {
322
+ refs.current.lastDoAnimate[key] = prevDont[key]
323
+ }
324
+ }
325
+ }
326
+ }
327
+
328
+ const lastAnimated = refs.current.lastDoAnimate
329
+ if (lastAnimated) {
330
+ removeRemovedStyles(lastAnimated, doAnimate, node, dontAnimate)
331
+ }
332
+
333
+ const diff = getDiff(refs.current.lastDoAnimate, doAnimate)
334
+
335
+ if (diff) {
336
+ // capture frozen exit target on first exit diff
337
+ if (isCurrentlyExiting && !refs.current.frozenExitTarget) {
338
+ refs.current.frozenExitTarget = { ...doAnimate }
339
+ }
340
+
341
+ // capture mid-flight values so we can provide explicit [from, to]
342
+ // keyframes to WAAPI, ensuring smooth interpolation from the
343
+ // current visual state.
344
+ //
345
+ // only stop() during exit — for non-exit cases, WAAPI
346
+ // naturally replaces only conflicting property animations,
347
+ // letting non-conflicting ones (like an in-flight enter
348
+ // opacity animation) continue to completion.
349
+ const isPopperPosition = node.hasAttribute('data-popper-animate-position')
350
+ let midFlightValues: Record<string, string> | null = null
351
+ if (refs.current.controls) {
352
+ try {
353
+ const computed = getComputedStyle(node)
354
+ midFlightValues = {}
355
+ for (const key in diff) {
356
+ const val = (computed as any)[key]
357
+ if (val !== undefined && val !== '') {
358
+ midFlightValues[key] = val
359
+ }
360
+ }
361
+ } catch {
362
+ // getComputedStyle can fail on detached nodes
363
+ }
364
+
365
+ if (isCurrentlyExiting) {
366
+ refs.current.controls.stop()
367
+ }
368
+
369
+ // write mid-flight values to inline so the 1-frame gap
370
+ // (while motion resolves keyframes) shows the correct
371
+ // position instead of stale inline styles
372
+ if (midFlightValues) {
373
+ for (const key in midFlightValues) {
374
+ ;(node.style as any)[key] = midFlightValues[key]
375
+ }
376
+ }
377
+
378
+ // for popper position elements, cancel WAAPI animations
379
+ // directly so motion.dev's internal stop() sees "idle" state
380
+ // and skips commitStyles. without this, commitStyles writes
381
+ // a mid-flight transform that's visible for 1 frame before
382
+ // the new animation starts, causing a flash toward (0,0).
383
+ if (isPopperPosition) {
384
+ const anims = node.getAnimations()
385
+ for (const anim of anims) {
386
+ anim.cancel()
387
+ }
388
+ }
389
+ }
390
+
391
+ const fixedDiff = fixTransparentColors(
392
+ diff,
393
+ refs.current.lastDoAnimate,
394
+ doAnimate
395
+ )
396
+
397
+ // provide explicit [from, to] keyframe for transforms during
398
+ // mid-flight interruption so motion starts from the right place
399
+ if (midFlightValues?.transform && fixedDiff.transform) {
400
+ fixedDiff.transform = [midFlightValues.transform, fixedDiff.transform]
401
+ }
402
+
403
+ startedControls = animate(scope.current, fixedDiff, animationOptions)
404
+ refs.current.controls = startedControls
405
+ refs.current.lastAnimateAt = Date.now()
406
+ }
407
+ }
408
+
409
+ refs.current.lastDontAnimate = dontAnimate ? { ...dontAnimate } : {}
410
+ refs.current.lastDoAnimate = doAnimate ? { ...doAnimate } : {}
411
+ } finally {
412
+ // exit completion: notify AnimatePresence when exit animation finishes
413
+ if (isCurrentlyExiting && currentSendExitComplete) {
414
+ if (startedControls) {
415
+ // new animation started — attach completion handler
416
+ refs.current.exitCompleteScheduled = true
417
+ startedControls.finished
418
+ .then(() => {
419
+ // guard: only complete if still exiting (prevents stale promise
420
+ // from calling sendExitComplete after a re-entry cancels the exit)
421
+ if (refs.current.isExiting) {
422
+ currentSendExitComplete()
423
+ }
424
+ })
425
+ .catch(() => {
426
+ if (refs.current.isExiting) {
427
+ currentSendExitComplete()
428
+ }
429
+ })
430
+ } else if (!refs.current.exitCompleteScheduled) {
431
+ // no animation started AND none previously scheduled (e.g. diff=null
432
+ // on re-render mid-exit because frozenExitTarget matches lastDoAnimate)
433
+ // — complete immediately only if we've never started an exit animation
434
+ currentSendExitComplete()
435
+ }
436
+ // else: exit animation already scheduled via a previous flush,
437
+ // its .finished promise will call sendExitComplete when done
438
+ }
439
+ }
440
+ }
441
+
442
+ useStyleEmitter?.((nextStyle, effectiveTransition) => {
443
+ const animationProps = getMotionAnimatedProps(
444
+ props as any,
445
+ nextStyle,
446
+ disableAnimation,
447
+ refs.current.animationState,
448
+ effectiveTransition
449
+ )
450
+
451
+ flushAnimation(animationProps)
452
+ })
453
+
454
+ useIsomorphicLayoutEffect(() => {
455
+ if (refs.current.isFirstRender) {
456
+ refs.current.isFirstRender = false
457
+
458
+ // during hydration, skip inline style writes entirely — SSR CSS
459
+ // already has the correct values. writing them again as inline
460
+ // styles triggers browser style recalc that causes visible font
461
+ // flashes (fontWeight, fontSize, letterSpacing, lineHeight).
462
+ // we only need to track refs for future animation diffing.
463
+ if (isHydrating) {
464
+ if (doAnimate && Object.keys(doAnimate).length > 0) {
465
+ refs.current.lastDoAnimate = { ...doAnimate }
466
+ } else {
467
+ refs.current.lastDoAnimate = dontAnimate ? { ...dontAnimate } : {}
468
+ }
469
+
470
+ refs.current.lastDontAnimate = dontAnimate ? { ...dontAnimate } : {}
471
+ refs.current.lastAnimateAt = Date.now()
472
+ return
473
+ }
474
+
475
+ // after hydration, use simpler logic
476
+ refs.current.lastDontAnimate = dontAnimate ? { ...dontAnimate } : {}
477
+ refs.current.lastDoAnimate = doAnimate ? { ...doAnimate } : {}
478
+ return
479
+ }
480
+
481
+ flushAnimation({
482
+ doAnimate,
483
+ dontAnimate,
484
+ animationOptions,
485
+ })
486
+ }, [style, isExiting, disableAnimation])
487
+
488
+ if (process.env.NODE_ENV === 'development') {
489
+ if (props['debug'] && props['debug'] !== 'profile') {
490
+ console.groupCollapsed(`[motion] render`)
491
+ console.info({
492
+ style,
493
+ doAnimate,
494
+ dontAnimate,
495
+ scope,
496
+ animationOptions,
497
+ isExiting,
498
+ isFirstRender: refs.current.isFirstRender,
499
+ animationProps,
500
+ })
501
+ console.groupEnd()
502
+ }
503
+ }
504
+
505
+ return {
506
+ style: firstRenderStyle,
507
+ ref: scope,
508
+ render: 'div',
509
+ }
510
+ },
511
+
512
+ useAnimatedNumber(initial): UniversalAnimatedNumber<MotionAnimatedNumber> {
513
+ const motionValue = useMotionValue(initial)
514
+
515
+ return React.useMemo(
516
+ () => ({
517
+ getInstance() {
518
+ return motionValue
519
+ },
520
+ getValue() {
521
+ return motionValue.get()
522
+ },
523
+ setValue(next, config = { type: 'spring' }, onFinish) {
524
+ if (config.type === 'direct') {
525
+ MotionValueStrategy.set(motionValue, { type: 'direct' })
526
+ motionValue.set(next)
527
+ onFinish?.()
528
+ return
529
+ }
530
+
531
+ MotionValueStrategy.set(motionValue, config)
532
+
533
+ // we intentionally DO NOT animate the motion value itself here
534
+ // (via framer-motion's imperative animate(motionValue, next)).
535
+ // doing so drives the JS value over time, which fires a 'change'
536
+ // event per frame, and each change event kicks off a new DOM
537
+ // animate(node, ...) that cancels the previous one — the DOM
538
+ // never reaches the target (double-animation stall).
539
+ //
540
+ // instead we jump the motion value to `next` synchronously. the
541
+ // animated component's change handler receives a single change
542
+ // event, computes the final webStyle, and drives the visible
543
+ // animation via DOM animate(node, webStyle, springConfig). that
544
+ // DOM animation is the real timing source.
545
+ //
546
+ // to make `onFinish` resolve when the VISIBLE animation finishes
547
+ // (not synchronously on the change event), we stash it in
548
+ // PendingMotionOnFinish here and the change handler chains it to
549
+ // the DOM animate() controls.
550
+ if (onFinish) {
551
+ // if a previous setValue is still pending on this motion value,
552
+ // fire it now — the new setValue will cancel the prior DOM
553
+ // animation, and the caller is still owed a completion signal.
554
+ const prior = PendingMotionOnFinish.get(motionValue)
555
+ if (prior) {
556
+ PendingMotionOnFinish.delete(motionValue)
557
+ prior()
558
+ }
559
+ PendingMotionOnFinish.set(motionValue, onFinish)
560
+ }
561
+
562
+ motionValue.set(next)
563
+ },
564
+ stop() {
565
+ motionValue.stop()
566
+ },
567
+ }),
568
+ [motionValue]
569
+ )
570
+ },
571
+
572
+ useAnimatedNumberReaction({ value }, onValue) {
573
+ const instance = value.getInstance() as MotionValue<number>
574
+ useMotionValueEvent(instance, 'change', onValue)
575
+ },
576
+
577
+ useAnimatedNumberStyle(val, getStyleProp) {
578
+ const motionValue = val.getInstance() as MotionValue<number>
579
+ const getStyleRef = useRef<typeof getStyleProp>(getStyleProp)
580
+
581
+ // we need to change useAnimatedNumberStyle to have dep args to be concurrent safe
582
+ getStyleRef.current = getStyleProp
583
+
584
+ return useMemo(() => {
585
+ return {
586
+ getStyle: (cur) => {
587
+ return getStyleRef.current(cur)
588
+ },
589
+ motionValue,
590
+ } satisfies MotionAnimatedNumberStyle
591
+ }, [])
592
+ },
593
+
594
+ useAnimatedNumbersStyle(vals, getStyleProp) {
595
+ const motionValues = vals.map((v) => v.getInstance() as MotionValue<number>)
596
+ const getStyleRef = useRef<typeof getStyleProp>(getStyleProp)
597
+ getStyleRef.current = getStyleProp
598
+
599
+ return useMemo(() => {
600
+ return {
601
+ getStyle: (...currentValues: number[]) => getStyleRef.current(...currentValues),
602
+ motionValues,
603
+ } satisfies MotionAnimatedNumberStyle
604
+ }, [])
605
+ },
606
+ }
607
+
608
+ function getMotionAnimatedProps(
609
+ props: { transition?: TransitionProp | null; animateOnly?: string[] },
610
+ style: Record<string, unknown>,
611
+ disable: boolean,
612
+ animationState: 'enter' | 'exit' | 'default' = 'default',
613
+ transitionOverride?: TransitionProp | null
614
+ ): AnimationProps {
615
+ if (disable) {
616
+ return {
617
+ dontAnimate: style,
618
+ }
619
+ }
620
+
621
+ const animationOptions = getAnimationOptions(
622
+ transitionOverride ?? props.transition ?? null,
623
+ animationState
624
+ )
625
+
626
+ let dontAnimate: Record<string, unknown> | undefined
627
+ let doAnimate: Record<string, unknown> | undefined
628
+
629
+ const animateOnly = props.animateOnly as string[] | undefined
630
+ for (const key in style) {
631
+ const value = style[key]
632
+ if (disableAnimationProps.has(key) || (animateOnly && !animateOnly.includes(key))) {
633
+ dontAnimate ||= {}
634
+ dontAnimate[key] = value
635
+ } else {
636
+ doAnimate ||= {}
637
+ doAnimate[key] = value
638
+ }
639
+ }
640
+
641
+ return {
642
+ dontAnimate,
643
+ doAnimate,
644
+ animationOptions,
645
+ }
646
+ }
647
+
648
+ function getAnimationOptions(
649
+ transitionProp: TransitionProp | null,
650
+ animationState: 'enter' | 'exit' | 'default' = 'default'
651
+ ): TransitionAnimationOptions {
652
+ const normalized = normalizeTransition(transitionProp)
653
+
654
+ let effectiveKey = getEffectiveAnimation(normalized, animationState)
655
+
656
+ // fallback: if we have enter/exit defined but state is 'default' and no default key,
657
+ // use enter timing as fallback to avoid empty animation options
658
+ if (!effectiveKey && animationState === 'default') {
659
+ effectiveKey = normalized.enter || normalized.exit || null
660
+ }
661
+
662
+ const globalConfigOverride: Record<string, unknown> | undefined = normalized.config
663
+ ? { ...normalized.config }
664
+ : undefined
665
+
666
+ if (
667
+ !effectiveKey &&
668
+ Object.keys(normalized.properties).length === 0 &&
669
+ !globalConfigOverride
670
+ ) {
671
+ return {}
672
+ }
673
+
674
+ const defaultConfig = effectiveKey ? withInferredType(animations[effectiveKey]) : null
675
+
676
+ const delay = normalized.delay
677
+
678
+ // framer motion's animate() expects default config at the TOP LEVEL
679
+ const result: TransitionAnimationOptions = {}
680
+
681
+ if (defaultConfig) {
682
+ Object.assign(result, defaultConfig)
683
+ }
684
+
685
+ if (globalConfigOverride) {
686
+ Object.assign(result, globalConfigOverride)
687
+ if (
688
+ result.type === undefined &&
689
+ result.duration !== undefined &&
690
+ result.damping === undefined &&
691
+ result.stiffness === undefined &&
692
+ result.mass === undefined
693
+ ) {
694
+ result.type = 'tween'
695
+ }
696
+ }
697
+
698
+ if (delay) {
699
+ result.delay = delay
700
+ }
701
+
702
+ if (defaultConfig || globalConfigOverride || delay) {
703
+ result.default = {
704
+ ...defaultConfig,
705
+ ...globalConfigOverride,
706
+ ...(delay ? { delay } : null),
707
+ }
708
+ }
709
+
710
+ for (const [propName, animationNameOrConfig] of Object.entries(
711
+ normalized.properties
712
+ )) {
713
+ if (typeof animationNameOrConfig === 'string') {
714
+ result[propName] = withInferredType(animations[animationNameOrConfig])
715
+ } else if (animationNameOrConfig && typeof animationNameOrConfig === 'object') {
716
+ const baseConfig = animationNameOrConfig.type
717
+ ? withInferredType(animations[animationNameOrConfig.type])
718
+ : defaultConfig
719
+
720
+ result[propName] = {
721
+ ...baseConfig,
722
+ ...animationNameOrConfig,
723
+ } as ValueTransition
724
+ }
725
+ }
726
+
727
+ // we standardize to ms across drivers, motion expects s
728
+ convertMsToS(result as ValueTransition)
729
+ convertMsToS(result.default)
730
+ for (const key in result) {
731
+ if (key !== 'default' && typeof result[key] === 'object') {
732
+ convertMsToS(result[key])
733
+ }
734
+ }
735
+
736
+ return result
737
+ }
738
+ }
739
+
740
+ function withInferredType(config: AnimationConfig | undefined): AnimationConfig {
741
+ if (!config) {
742
+ return { type: 'spring' }
743
+ }
744
+ const isTimingBased =
745
+ config.duration !== undefined &&
746
+ config.damping === undefined &&
747
+ config.stiffness === undefined &&
748
+ config.mass === undefined
749
+ return { type: isTimingBased ? 'tween' : 'spring', ...config }
750
+ }
751
+
752
+ function convertMsToS(config: ValueTransition | undefined) {
753
+ if (!config) return
754
+ if (typeof config.delay === 'number') config.delay = config.delay / 1000
755
+ if (typeof config.duration === 'number') {
756
+ const isTimingBased =
757
+ config.type === 'tween' ||
758
+ (config.type === undefined &&
759
+ config.damping === undefined &&
760
+ config.stiffness === undefined &&
761
+ config.mass === undefined)
762
+ if (isTimingBased) {
763
+ config.duration = config.duration / 1000
764
+ }
765
+ }
766
+ }
767
+
768
+ function removeRemovedStyles(
769
+ prev: object,
770
+ next: object,
771
+ node: HTMLElement,
772
+ dontClearIfIn?: object
773
+ ) {
774
+ for (const key in prev) {
775
+ if (!(key in next)) {
776
+ if (dontClearIfIn && key in dontClearIfIn) {
777
+ continue
778
+ }
779
+ node.style[key] = ''
780
+ }
781
+ }
782
+ }
783
+
784
+ // truly non-animatable CSS properties (discrete, keyword-based, no interpolation)
785
+ // properties like margin, maxHeight, zIndex, etc are animatable and intentionally excluded
786
+ export const disableAnimationProps: Set<string> = new Set<string>([
787
+ 'alignContent',
788
+ 'alignItems',
789
+ 'boxSizing',
790
+ 'contain',
791
+ 'containerType',
792
+ 'display',
793
+ 'flexBasis',
794
+ 'flexDirection',
795
+ 'fontFamily',
796
+ 'justifyContent',
797
+ 'overflow',
798
+ 'overflowX',
799
+ 'overflowY',
800
+ 'pointerEvents',
801
+ 'position',
802
+ 'textWrap',
803
+ 'userSelect',
804
+ ])
805
+
806
+ const MotionView = createMotionView('div')
807
+ const MotionText = createMotionView('span')
808
+
809
+ function createMotionView(defaultTag: string) {
810
+ const isText = defaultTag === 'span'
811
+
812
+ const Component = forwardRef((propsIn: any, ref) => {
813
+ const { forwardedRef, animation, render = defaultTag, style, ...propsRest } = propsIn
814
+ const [scope, animate] = useAnimateSSRSafe()
815
+ const hostRef = useRef<HTMLElement>(null)
816
+ const composedRefs = useComposedRefs(forwardedRef, ref, hostRef, scope)
817
+
818
+ const stateRef = useRef<any>(null)
819
+ if (!stateRef.current) {
820
+ stateRef.current = {
821
+ get host() {
822
+ return hostRef.current
823
+ },
824
+ }
825
+ }
826
+
827
+ const [_, state] = useThemeWithState({})
828
+
829
+ const styles = Array.isArray(style) ? style : [style]
830
+
831
+ const [animatedStyle, nonAnimatedStyles] = (() => {
832
+ let animatedStyle: MotionAnimatedNumberStyle | undefined
833
+ const nonAnimatedStyles: typeof styles = []
834
+ for (const style of styles) {
835
+ if (style.getStyle) {
836
+ animatedStyle = style as MotionAnimatedNumberStyle
837
+ } else {
838
+ nonAnimatedStyles.push(style)
839
+ }
840
+ }
841
+ return [animatedStyle, nonAnimatedStyles] as const
842
+ })()
843
+
844
+ function getProps(props: any) {
845
+ const out = getSplitStyles(
846
+ props,
847
+ isText ? Text.staticConfig : View.staticConfig,
848
+ state?.theme,
849
+ state?.name,
850
+ {
851
+ unmounted: false,
852
+ },
853
+ {
854
+ isAnimated: false,
855
+ noClass: true,
856
+ resolveValues: 'auto',
857
+ }
858
+ )
859
+
860
+ if (!out) {
861
+ return {}
862
+ }
863
+
864
+ if (out.viewProps.style) {
865
+ fixStyles(out.viewProps.style)
866
+ styleToCSS(out.viewProps.style)
867
+ }
868
+
869
+ return out.viewProps
870
+ }
871
+
872
+ const props = getProps({ ...propsRest, style: nonAnimatedStyles })
873
+ const Element = render || 'div'
874
+ const transformedProps = hooks.usePropsTransform?.(render, props, stateRef, false)
875
+
876
+ useEffect(() => {
877
+ if (!animatedStyle) return
878
+
879
+ // multi-value path: subscribe to all motion values
880
+ if (animatedStyle.motionValues) {
881
+ const mvs = animatedStyle.motionValues
882
+ const unsubs = mvs.map((mv) =>
883
+ mv.on('change', () => {
884
+ const currentValues = mvs.map((v) => v.get())
885
+ const nextStyle = animatedStyle.getStyle(...currentValues)
886
+ const animationConfig = MotionValueStrategy.get(mv)
887
+ const node = hostRef.current
888
+
889
+ const webStyle = getProps({ style: nextStyle }).style
890
+
891
+ if (webStyle && node instanceof HTMLElement) {
892
+ const motionAnimationConfig =
893
+ animationConfig?.type === 'timing'
894
+ ? { type: 'tween', duration: (animationConfig?.duration || 0) / 1000 }
895
+ : animationConfig?.type === 'direct'
896
+ ? { type: 'tween', duration: 0 }
897
+ : { type: 'spring', ...(animationConfig as any) }
898
+
899
+ const controls = animate(node, webStyle as any, motionAnimationConfig)
900
+ settlePendingMotionOnFinish(mv, controls)
901
+ }
902
+ })
903
+ )
904
+ return () => unsubs.forEach((fn) => fn())
905
+ }
906
+
907
+ // single-value path
908
+ if (!animatedStyle.motionValue) return
909
+
910
+ return animatedStyle.motionValue.on('change', (value) => {
911
+ const nextStyle = animatedStyle.getStyle(value)
912
+ const animationConfig = MotionValueStrategy.get(animatedStyle.motionValue!)
913
+ const node = hostRef.current
914
+
915
+ const webStyle = getProps({ style: nextStyle }).style
916
+
917
+ if (webStyle && node instanceof HTMLElement) {
918
+ const motionAnimationConfig =
919
+ animationConfig?.type === 'timing'
920
+ ? {
921
+ type: 'tween',
922
+ duration: (animationConfig?.duration || 0) / 1000,
923
+ }
924
+ : animationConfig?.type === 'direct'
925
+ ? { type: 'tween', duration: 0 }
926
+ : {
927
+ type: 'spring',
928
+ ...(animationConfig as any),
929
+ }
930
+
931
+ const controls = animate(node, webStyle as any, motionAnimationConfig)
932
+ settlePendingMotionOnFinish(animatedStyle.motionValue!, controls)
933
+ }
934
+ })
935
+ }, [animatedStyle])
936
+
937
+ return <Element {...transformedProps} ref={composedRefs} />
938
+ })
939
+
940
+ Component['acceptRenderProp'] = true
941
+
942
+ return Component
943
+ }
944
+
945
+ function getDiff<T extends Record<string, unknown>>(
946
+ previous: T | null,
947
+ next: T
948
+ ): Record<string, unknown> | null {
949
+ if (!previous) {
950
+ return next
951
+ }
952
+
953
+ let diff: Record<string, unknown> | null = null
954
+ for (const key in next) {
955
+ if (next[key] !== previous[key]) {
956
+ diff ||= {}
957
+ diff[key] = next[key]
958
+ }
959
+ }
960
+ return diff
961
+ }
962
+
963
+ // motion.dev can't animate to "transparent" - convert it to rgba
964
+ // try to extract RGB from previous or next value for smooth color transitions
965
+ function fixTransparentColors(
966
+ diff: Record<string, unknown>,
967
+ previous: Record<string, unknown> | null,
968
+ next?: Record<string, unknown> | null
969
+ ): Record<string, unknown> {
970
+ let result = diff
971
+ for (const key in diff) {
972
+ if (diff[key] === 'transparent') {
973
+ let fixed = 'rgba(0, 0, 0, 0)'
974
+ const candidates = [previous?.[key], next?.[key]]
975
+ for (const candidate of candidates) {
976
+ if (typeof candidate === 'string' && candidate !== 'transparent') {
977
+ const rgbaMatch = candidate.match(/^rgba?\(([^,]+),\s*([^,]+),\s*([^,)]+)/)
978
+ if (rgbaMatch) {
979
+ fixed = `rgba(${rgbaMatch[1]}, ${rgbaMatch[2]}, ${rgbaMatch[3]}, 0)`
980
+ break
981
+ }
982
+ }
983
+ }
984
+ if (result === diff) {
985
+ result = { ...diff }
986
+ }
987
+ result[key] = fixed
988
+ }
989
+ }
990
+ return result
991
+ }