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