@humanspeak/svelte-motion 0.1.14 → 0.1.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/components/AnimatePresence.svelte +20 -4
- package/dist/components/AnimatePresence.svelte.d.ts +1 -0
- package/dist/html/_MotionContainer.svelte +274 -34
- package/dist/index.d.ts +5 -1
- package/dist/index.js +4 -0
- package/dist/types.d.ts +36 -0
- package/dist/utils/animation.js +11 -5
- package/dist/utils/inView.d.ts +79 -0
- package/dist/utils/inView.js +179 -0
- package/dist/utils/presence.d.ts +48 -0
- package/dist/utils/presence.js +199 -3
- package/dist/utils/styleObject.d.ts +20 -1
- package/dist/utils/styleObject.js +52 -4
- package/dist/utils/styleObject.svelte.d.ts +27 -0
- package/dist/utils/styleObject.svelte.js +31 -0
- package/dist/utils/svg.d.ts +19 -0
- package/dist/utils/svg.js +83 -0
- package/package.json +22 -22
package/README.md
CHANGED
|
@@ -117,7 +117,7 @@ This package carefully selects its dependencies to provide a robust and maintain
|
|
|
117
117
|
| [Fancy Like Button](https://github.com/DRlFTER/fancyLikeButton) | `/tests/random/fancy-like-button` | [View Example](https://svelte.dev/playground/c34b7e53d41c48b0ab1eaf21ca120c6e?version=5.38.10) |
|
|
118
118
|
| [Keyframes (square → circle → square; scale 1→2→1)](https://motion.dev/docs/react-animation#keyframes) | `/tests/motion/keyframes` | [View Example](https://svelte.dev/playground/05595ce0db124c1cbbe4e74fda68d717?version=5.38.10) |
|
|
119
119
|
| [Animated Border Gradient (conic-gradient rotate)](https://www.youtube.com/watch?v=OgQI1-9T6ZA) | `/tests/random/animated-border-gradient` | [View Example](https://svelte.dev/playground/6983a61b4c35441b8aa72a971de01a23?version=5.38.10) |
|
|
120
|
-
| [Exit Animation](https://motion.dev/docs/react#exit-animations) | `/tests/
|
|
120
|
+
| [Exit Animation](https://motion.dev/docs/react#exit-animations) | `/tests/animate-presence/basic` | [View Example](https://svelte.dev/playground/ef277e283d864653ace54e7453801601?version=5.38.10) |
|
|
121
121
|
|
|
122
122
|
## Interactions
|
|
123
123
|
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Snippet } from 'svelte'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
createAnimatePresenceContext,
|
|
5
|
+
setAnimatePresenceContext,
|
|
6
|
+
setPresenceDepth
|
|
7
|
+
} from '../utils/presence'
|
|
8
|
+
import { pwLog } from '../utils/log'
|
|
4
9
|
|
|
5
10
|
/**
|
|
6
11
|
* Provide `AnimatePresence` context to descendants.
|
|
@@ -10,15 +15,26 @@
|
|
|
10
15
|
* styled clone is animated out before being removed from the DOM.
|
|
11
16
|
*
|
|
12
17
|
* @prop children Slotted content participating in presence.
|
|
18
|
+
* @prop initial When false, children skip their enter animation on initial mount.
|
|
13
19
|
* @prop onExitComplete Optional callback invoked once all exits complete.
|
|
14
20
|
*/
|
|
15
|
-
const {
|
|
21
|
+
const {
|
|
22
|
+
children,
|
|
23
|
+
initial = true,
|
|
24
|
+
onExitComplete
|
|
25
|
+
} = $props<{
|
|
16
26
|
children?: Snippet
|
|
27
|
+
initial?: boolean
|
|
17
28
|
onExitComplete?: () => void
|
|
18
29
|
}>()
|
|
19
30
|
|
|
20
|
-
|
|
31
|
+
pwLog('[AnimatePresence] mounting', { initial, hasOnExitComplete: !!onExitComplete })
|
|
32
|
+
const context = createAnimatePresenceContext({ initial, onExitComplete })
|
|
21
33
|
setAnimatePresenceContext(context)
|
|
34
|
+
|
|
35
|
+
// Initialize presence depth to 0 for direct children
|
|
36
|
+
// Only direct children (depth 0) require explicit key props, matching Framer Motion behavior
|
|
37
|
+
setPresenceDepth(0)
|
|
22
38
|
</script>
|
|
23
39
|
|
|
24
40
|
<div class="animate-presence-container">
|
|
@@ -27,6 +43,6 @@
|
|
|
27
43
|
|
|
28
44
|
<style>
|
|
29
45
|
.animate-presence-container {
|
|
30
|
-
|
|
46
|
+
display: contents;
|
|
31
47
|
}
|
|
32
48
|
</style>
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
// Module-level counter for deterministic key generation (avoids SSR hydration mismatch)
|
|
3
|
+
let keyCounter = 0
|
|
4
|
+
</script>
|
|
5
|
+
|
|
1
6
|
<script lang="ts">
|
|
2
7
|
import { getMotionConfig } from '../components/motionConfig.context'
|
|
3
8
|
import type {
|
|
@@ -20,6 +25,7 @@
|
|
|
20
25
|
import { attachWhileTap } from '../utils/interaction'
|
|
21
26
|
import { attachWhileHover } from '../utils/hover'
|
|
22
27
|
import { attachWhileFocus } from '../utils/focus'
|
|
28
|
+
import { attachWhileInView } from '../utils/inView'
|
|
23
29
|
import {
|
|
24
30
|
measureRect,
|
|
25
31
|
computeFlipTransforms,
|
|
@@ -30,7 +36,12 @@
|
|
|
30
36
|
import type { SvelteHTMLElements } from 'svelte/elements'
|
|
31
37
|
import { mergeInlineStyles } from '../utils/style'
|
|
32
38
|
import { isNativelyFocusable } from '../utils/a11y'
|
|
33
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
usePresence,
|
|
41
|
+
getAnimatePresenceContext,
|
|
42
|
+
getPresenceDepth,
|
|
43
|
+
setPresenceDepth
|
|
44
|
+
} from '../utils/presence'
|
|
34
45
|
import { getInitialKeyframes } from '../utils/initial'
|
|
35
46
|
import { attachDrag } from '../utils/drag'
|
|
36
47
|
import { resolveInitial, resolveAnimate, resolveExit } from '../utils/variants'
|
|
@@ -41,7 +52,12 @@
|
|
|
41
52
|
getInitialFalseContext
|
|
42
53
|
} from '../components/variantContext.context'
|
|
43
54
|
import { writable } from 'svelte/store'
|
|
44
|
-
import {
|
|
55
|
+
import {
|
|
56
|
+
transformSVGPathProperties,
|
|
57
|
+
computeNormalizedSVGInitialAttrs,
|
|
58
|
+
isSVGTag,
|
|
59
|
+
SVG_NAMESPACE
|
|
60
|
+
} from '../utils/svg'
|
|
45
61
|
|
|
46
62
|
type Props = MotionProps & {
|
|
47
63
|
children?: Snippet
|
|
@@ -52,6 +68,7 @@
|
|
|
52
68
|
let {
|
|
53
69
|
children,
|
|
54
70
|
tag = 'div',
|
|
71
|
+
key: keyProp,
|
|
55
72
|
variants: variantsProp,
|
|
56
73
|
initial: initialProp,
|
|
57
74
|
animate: animateProp,
|
|
@@ -64,11 +81,14 @@
|
|
|
64
81
|
whileTap: whileTapProp,
|
|
65
82
|
whileHover: whileHoverProp,
|
|
66
83
|
whileFocus: whileFocusProp,
|
|
84
|
+
whileInView: whileInViewProp,
|
|
67
85
|
whileDrag: whileDragProp,
|
|
68
86
|
onHoverStart: onHoverStartProp,
|
|
69
87
|
onHoverEnd: onHoverEndProp,
|
|
70
88
|
onFocusStart: onFocusStartProp,
|
|
71
89
|
onFocusEnd: onFocusEndProp,
|
|
90
|
+
onInViewStart: onInViewStartProp,
|
|
91
|
+
onInViewEnd: onInViewEndProp,
|
|
72
92
|
onTapStart: onTapStartProp,
|
|
73
93
|
onTap: onTapProp,
|
|
74
94
|
onTapCancel: onTapCancelProp,
|
|
@@ -95,8 +115,35 @@
|
|
|
95
115
|
let dataPath = $state<number>(-1)
|
|
96
116
|
const motionConfig = $derived(getMotionConfig())
|
|
97
117
|
|
|
98
|
-
//
|
|
99
|
-
const
|
|
118
|
+
// Get presence context to check if we're inside AnimatePresence
|
|
119
|
+
const context = getAnimatePresenceContext()
|
|
120
|
+
|
|
121
|
+
// Get current presence depth (0 = direct child of AnimatePresence, undefined = not in AnimatePresence)
|
|
122
|
+
const presenceDepth = getPresenceDepth()
|
|
123
|
+
|
|
124
|
+
// Validate key prop only for direct children of AnimatePresence (depth 0)
|
|
125
|
+
// This matches Framer Motion behavior where only immediate children need keys
|
|
126
|
+
if (context && presenceDepth === 0 && !keyProp) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
'motion elements that are direct children of AnimatePresence must have a `key` prop. ' +
|
|
129
|
+
'Example: <motion.div key="unique-id" />'
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Increment depth for descendants so nested motion elements don't require keys
|
|
134
|
+
if (presenceDepth !== undefined) {
|
|
135
|
+
setPresenceDepth(presenceDepth + 1)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Use the provided key for presence tracking
|
|
139
|
+
// When not inside AnimatePresence, use a stable identifier based on component instance
|
|
140
|
+
const presenceKey = keyProp ?? `motion-${++keyCounter}`
|
|
141
|
+
|
|
142
|
+
// Track previous key for key-change detection (simulates React's key-based remounting)
|
|
143
|
+
// Using $state for idiomatic Svelte 5 reactivity
|
|
144
|
+
let keyTrackerPrev = $state(keyProp)
|
|
145
|
+
let keyTrackerIsTransitioning = $state(false)
|
|
146
|
+
let keyTransitionStopped = $state(false)
|
|
100
147
|
|
|
101
148
|
// Compute merged transition without mutating props to avoid effect write loops
|
|
102
149
|
const mergedTransition = $derived<AnimationOptions>(
|
|
@@ -118,12 +165,10 @@
|
|
|
118
165
|
}
|
|
119
166
|
})
|
|
120
167
|
|
|
121
|
-
const context = getAnimatePresenceContext()
|
|
122
168
|
// Update presence context with current state when element is ready and has size
|
|
123
169
|
$effect(() => {
|
|
124
170
|
if (!(context && element && isLoaded === 'ready')) return
|
|
125
171
|
|
|
126
|
-
let rafId: number | null = null
|
|
127
172
|
let lastWidth = 0
|
|
128
173
|
let lastHeight = 0
|
|
129
174
|
let stopped = false
|
|
@@ -132,11 +177,6 @@
|
|
|
132
177
|
if (stopped || !element || !element.isConnected) return
|
|
133
178
|
const rect = element.getBoundingClientRect()
|
|
134
179
|
const style = getComputedStyle(element)
|
|
135
|
-
pwLog('[motion][measure]', {
|
|
136
|
-
w: rect.width,
|
|
137
|
-
h: rect.height,
|
|
138
|
-
transform: style.transform
|
|
139
|
-
})
|
|
140
180
|
if (
|
|
141
181
|
Math.abs(rect.width - lastWidth) > 0.5 ||
|
|
142
182
|
Math.abs(rect.height - lastHeight) > 0.5
|
|
@@ -149,6 +189,7 @@
|
|
|
149
189
|
|
|
150
190
|
// Observe size changes
|
|
151
191
|
const resizeObserver = new ResizeObserver(() => {
|
|
192
|
+
pwLog('[motion][resize]', { key: presenceKey })
|
|
152
193
|
measureAndUpdate()
|
|
153
194
|
})
|
|
154
195
|
try {
|
|
@@ -157,15 +198,8 @@
|
|
|
157
198
|
// Ignore
|
|
158
199
|
}
|
|
159
200
|
|
|
160
|
-
// Also poll on RAF to catch transform/layout-driven changes
|
|
161
|
-
const tick = () => {
|
|
162
|
-
if (stopped) return
|
|
163
|
-
measureAndUpdate()
|
|
164
|
-
rafId = requestAnimationFrame(tick)
|
|
165
|
-
}
|
|
166
|
-
rafId = requestAnimationFrame(tick)
|
|
167
|
-
|
|
168
201
|
// Initial measure once
|
|
202
|
+
pwLog('[motion][initial-measure]', { key: presenceKey })
|
|
169
203
|
measureAndUpdate()
|
|
170
204
|
|
|
171
205
|
return () => {
|
|
@@ -175,7 +209,6 @@
|
|
|
175
209
|
} catch {
|
|
176
210
|
// Ignore
|
|
177
211
|
}
|
|
178
|
-
if (rafId) cancelAnimationFrame(rafId)
|
|
179
212
|
}
|
|
180
213
|
})
|
|
181
214
|
|
|
@@ -217,13 +250,23 @@
|
|
|
217
250
|
)
|
|
218
251
|
|
|
219
252
|
// Propagate initial={false} to children BEFORE setting variant context
|
|
253
|
+
// AnimatePresence initial={false} only applies on first render - check shouldAnimateEnter(key)
|
|
220
254
|
const parentInitialFalse = getInitialFalseContext()
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
255
|
+
const presenceSkipEnter = context ? !context.shouldAnimateEnter(presenceKey) : false
|
|
256
|
+
const effectiveInitialProp = presenceSkipEnter
|
|
257
|
+
? false
|
|
258
|
+
: initialProp !== undefined
|
|
259
|
+
? initialProp
|
|
260
|
+
: parentInitialFalse && variantsProp
|
|
261
|
+
? false
|
|
262
|
+
: undefined
|
|
263
|
+
|
|
264
|
+
pwLog('[motion] mount', {
|
|
265
|
+
presenceSkipEnter,
|
|
266
|
+
effectiveInitialProp,
|
|
267
|
+
initialProp,
|
|
268
|
+
animateProp
|
|
269
|
+
})
|
|
227
270
|
|
|
228
271
|
if (initialProp === false) {
|
|
229
272
|
setInitialFalseContext(true)
|
|
@@ -279,8 +322,20 @@
|
|
|
279
322
|
initialKeyframes && 'pathLength' in initialKeyframes && isLoaded === 'mounting'
|
|
280
323
|
? `${styleProp || ''};visibility:hidden`
|
|
281
324
|
: styleProp,
|
|
282
|
-
initialKeyframes as
|
|
283
|
-
|
|
325
|
+
// Apply initialKeyframes as inline styles during mounting and initial phases
|
|
326
|
+
// The animation starts in RAF after 'initial' phase, so we need styles until then
|
|
327
|
+
// When ready AND we have initialKeyframes: DON'T set any animated properties!
|
|
328
|
+
// WAAPI is controlling them and inline styles can override the animation
|
|
329
|
+
isLoaded === 'mounting' || isLoaded === 'initial'
|
|
330
|
+
? (initialKeyframes as unknown as Record<string, unknown>)
|
|
331
|
+
: undefined,
|
|
332
|
+
// Only use resolvedAnimate as fallback when we DON'T have initialKeyframes
|
|
333
|
+
// If we have initialKeyframes, the enter animation is running - setting
|
|
334
|
+
// inline styles to the target values will override the WAAPI animation
|
|
335
|
+
// Use isNotEmpty to handle empty initial objects (initial: {}) which should fallback
|
|
336
|
+
isNotEmpty(initialKeyframes)
|
|
337
|
+
? undefined
|
|
338
|
+
: (resolvedAnimate as unknown as Record<string, unknown>)
|
|
284
339
|
),
|
|
285
340
|
class: classProp
|
|
286
341
|
})
|
|
@@ -357,7 +412,16 @@
|
|
|
357
412
|
})
|
|
358
413
|
|
|
359
414
|
const runAnimation = () => {
|
|
360
|
-
|
|
415
|
+
pwLog('[motion] runAnimation called', {
|
|
416
|
+
hasElement: !!element,
|
|
417
|
+
resolvedAnimate,
|
|
418
|
+
mergedTransition
|
|
419
|
+
})
|
|
420
|
+
if (!element || !resolvedAnimate) {
|
|
421
|
+
pwLog('[motion] runAnimation bailing - no element or resolvedAnimate')
|
|
422
|
+
return
|
|
423
|
+
}
|
|
424
|
+
|
|
361
425
|
const transitionAnimate: MotionTransition = mergedTransition ?? {}
|
|
362
426
|
let payload = $state.snapshot(resolvedAnimate)
|
|
363
427
|
|
|
@@ -373,6 +437,11 @@
|
|
|
373
437
|
;(element as HTMLElement).style.removeProperty('stroke-dashoffset')
|
|
374
438
|
}
|
|
375
439
|
|
|
440
|
+
pwLog('[motion] runAnimation animating', {
|
|
441
|
+
payload,
|
|
442
|
+
transitionAnimate
|
|
443
|
+
})
|
|
444
|
+
|
|
376
445
|
animateWithLifecycle(
|
|
377
446
|
element,
|
|
378
447
|
payload as unknown as DOMKeyframesDefinition,
|
|
@@ -385,6 +454,12 @@
|
|
|
385
454
|
// Track the last variant key we ran to avoid re-running on mount
|
|
386
455
|
let lastRanVariantKey = $state<string | undefined>(undefined)
|
|
387
456
|
let mountedWithInitialFalse = $state(false)
|
|
457
|
+
// Track if the initial->animate transition has already been triggered by main effect
|
|
458
|
+
let initialAnimationTriggered = $state(false)
|
|
459
|
+
// Track if we've run the animation for object animateProp on this mount
|
|
460
|
+
let objectAnimateRanOnMount = $state(false)
|
|
461
|
+
// Track the serialized animateProp to detect changes for object animate props
|
|
462
|
+
let lastAnimatePropJson = $state<string | undefined>(undefined)
|
|
388
463
|
const currentAnimateKey = $derived(
|
|
389
464
|
typeof animateProp === 'string'
|
|
390
465
|
? animateProp
|
|
@@ -487,6 +562,111 @@
|
|
|
487
562
|
)
|
|
488
563
|
})
|
|
489
564
|
|
|
565
|
+
// whileInView handling for viewport intersection
|
|
566
|
+
$effect(() => {
|
|
567
|
+
if (!(element && isLoaded === 'ready' && isNotEmpty(whileInViewProp))) return
|
|
568
|
+
return attachWhileInView(
|
|
569
|
+
element!,
|
|
570
|
+
(whileInViewProp ?? {}) as Record<string, unknown>,
|
|
571
|
+
(mergedTransition ?? {}) as AnimationOptions,
|
|
572
|
+
{
|
|
573
|
+
onStart: onInViewStartProp,
|
|
574
|
+
onEnd: onInViewEndProp,
|
|
575
|
+
onAnimationComplete: onAnimationCompleteProp
|
|
576
|
+
},
|
|
577
|
+
{
|
|
578
|
+
initial: (resolvedInitial ?? {}) as Record<string, unknown>,
|
|
579
|
+
animate: (resolvedAnimate ?? {}) as Record<string, unknown>
|
|
580
|
+
}
|
|
581
|
+
)
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
// Handle key prop changes inside AnimatePresence (simulates React's key-based remounting)
|
|
585
|
+
// When key changes, run exit → initial → animate sequence on the same element
|
|
586
|
+
$effect(() => {
|
|
587
|
+
// Access keyProp to create reactive dependency
|
|
588
|
+
const currentKey = keyProp
|
|
589
|
+
|
|
590
|
+
// Only handle key changes when:
|
|
591
|
+
// 1. We're inside AnimatePresence (context exists)
|
|
592
|
+
// 2. Element is ready (not during initial mount)
|
|
593
|
+
// 3. Key actually changed (not undefined → value on mount)
|
|
594
|
+
// 4. Not already transitioning
|
|
595
|
+
if (
|
|
596
|
+
!context ||
|
|
597
|
+
!element ||
|
|
598
|
+
isLoaded !== 'ready' ||
|
|
599
|
+
keyTrackerIsTransitioning ||
|
|
600
|
+
currentKey === keyTrackerPrev ||
|
|
601
|
+
keyTrackerPrev === undefined
|
|
602
|
+
) {
|
|
603
|
+
// Update prev for next comparison
|
|
604
|
+
if (currentKey !== keyTrackerPrev) {
|
|
605
|
+
keyTrackerPrev = currentKey
|
|
606
|
+
}
|
|
607
|
+
return
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
pwLog('[motion] key changed, running exit→initial→animate', {
|
|
611
|
+
prevKey: keyTrackerPrev,
|
|
612
|
+
newKey: currentKey
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
// Mark as transitioning to prevent re-entry
|
|
616
|
+
keyTrackerIsTransitioning = true
|
|
617
|
+
keyTrackerPrev = currentKey
|
|
618
|
+
|
|
619
|
+
// Run the key transition sequence
|
|
620
|
+
const runKeyTransition = async () => {
|
|
621
|
+
try {
|
|
622
|
+
// 1. Run exit animation if defined
|
|
623
|
+
if (resolvedExit && element && !keyTransitionStopped) {
|
|
624
|
+
const exitKeyframes = { ...(resolvedExit as Record<string, unknown>) }
|
|
625
|
+
// Remove transition from keyframes (it's passed separately)
|
|
626
|
+
delete exitKeyframes.transition
|
|
627
|
+
|
|
628
|
+
pwLog('[motion] key transition: running exit', { exitKeyframes })
|
|
629
|
+
await animate(
|
|
630
|
+
element,
|
|
631
|
+
exitKeyframes as DOMKeyframesDefinition,
|
|
632
|
+
mergedTransition
|
|
633
|
+
).finished
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Check if component was unmounted during exit animation
|
|
637
|
+
if (keyTransitionStopped || !element) return
|
|
638
|
+
|
|
639
|
+
// 2. Snap to initial state
|
|
640
|
+
if (initialKeyframes && element) {
|
|
641
|
+
const transformedInitial = transformSVGPathProperties(
|
|
642
|
+
element,
|
|
643
|
+
initialKeyframes as Record<string, unknown>
|
|
644
|
+
)
|
|
645
|
+
pwLog('[motion] key transition: snapping to initial', { transformedInitial })
|
|
646
|
+
animate(element, transformedInitial as DOMKeyframesDefinition, { duration: 0 })
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Check again before running enter animation
|
|
650
|
+
if (keyTransitionStopped || !element) return
|
|
651
|
+
|
|
652
|
+
// 3. Run enter animation
|
|
653
|
+
pwLog('[motion] key transition: running enter animation')
|
|
654
|
+
runAnimation()
|
|
655
|
+
} finally {
|
|
656
|
+
if (!keyTransitionStopped) {
|
|
657
|
+
keyTrackerIsTransitioning = false
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
runKeyTransition()
|
|
663
|
+
|
|
664
|
+
// Cleanup on unmount
|
|
665
|
+
return () => {
|
|
666
|
+
keyTransitionStopped = true
|
|
667
|
+
}
|
|
668
|
+
})
|
|
669
|
+
|
|
490
670
|
// Re-run animate when animateProp changes while ready
|
|
491
671
|
$effect(() => {
|
|
492
672
|
if (!(element && isLoaded === 'ready')) return
|
|
@@ -500,15 +680,39 @@
|
|
|
500
680
|
// Variant has changed, so we should animate
|
|
501
681
|
mountedWithInitialFalse = false
|
|
502
682
|
}
|
|
683
|
+
// Skip if the initial animation was already triggered by the main effect
|
|
684
|
+
if (initialAnimationTriggered) {
|
|
685
|
+
pwLog('[motion] effect: skipping, initial animation already triggered')
|
|
686
|
+
initialAnimationTriggered = false
|
|
687
|
+
// Also mark object animate as ran to prevent duplicate runs from effect re-triggers
|
|
688
|
+
if (animateProp && typeof animateProp !== 'string') {
|
|
689
|
+
objectAnimateRanOnMount = true
|
|
690
|
+
}
|
|
691
|
+
return
|
|
692
|
+
}
|
|
503
693
|
if (typeof animateProp === 'string') {
|
|
504
694
|
if (lastRanVariantKey !== animateProp) {
|
|
505
695
|
lastRanVariantKey = animateProp
|
|
506
696
|
runAnimation()
|
|
507
697
|
}
|
|
508
698
|
} else if (animateProp) {
|
|
509
|
-
// Object animate props -
|
|
510
|
-
|
|
511
|
-
|
|
699
|
+
// Object animate props - detect if the prop actually changed
|
|
700
|
+
const currentJson = JSON.stringify(animateProp)
|
|
701
|
+
const propChanged = lastAnimatePropJson !== currentJson
|
|
702
|
+
|
|
703
|
+
// Reset flag if animate prop changed
|
|
704
|
+
if (propChanged) {
|
|
705
|
+
objectAnimateRanOnMount = false
|
|
706
|
+
lastAnimatePropJson = currentJson
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Only run if we haven't already animated on this mount (or prop changed)
|
|
710
|
+
// This prevents duplicate animations when Svelte re-triggers the effect
|
|
711
|
+
if (!objectAnimateRanOnMount) {
|
|
712
|
+
objectAnimateRanOnMount = true
|
|
713
|
+
lastRanVariantKey = undefined
|
|
714
|
+
runAnimation()
|
|
715
|
+
}
|
|
512
716
|
}
|
|
513
717
|
})
|
|
514
718
|
|
|
@@ -539,9 +743,18 @@
|
|
|
539
743
|
$effect(() => {
|
|
540
744
|
if (!(element && isLoaded === 'mounting')) return
|
|
541
745
|
|
|
746
|
+
pwLog('[motion] main effect running', {
|
|
747
|
+
effectiveAnimate: !!effectiveAnimate,
|
|
748
|
+
effectiveInitialProp,
|
|
749
|
+
resolvedAnimate,
|
|
750
|
+
initialKeyframes,
|
|
751
|
+
hasInitialKeyframes: isNotEmpty(initialKeyframes)
|
|
752
|
+
})
|
|
753
|
+
|
|
542
754
|
if (effectiveAnimate) {
|
|
543
755
|
// If initial={false}, render at animate state immediately with no transition
|
|
544
756
|
if (effectiveInitialProp === false && resolvedAnimate) {
|
|
757
|
+
pwLog('[motion] path: initial=false, skip to animate')
|
|
545
758
|
// Use Motion's animate() with duration:0 so it takes control of these properties
|
|
546
759
|
// This prevents inline styles from pinning the properties during future animations
|
|
547
760
|
let snapshot = $state.snapshot(resolvedAnimate) as Record<string, unknown>
|
|
@@ -555,6 +768,7 @@
|
|
|
555
768
|
dataPath = 5
|
|
556
769
|
isLoaded = 'ready'
|
|
557
770
|
} else if (isNotEmpty(initialKeyframes)) {
|
|
771
|
+
pwLog('[motion] path: has initialKeyframes, will animate to target')
|
|
558
772
|
// Apply initial instantly BEFORE exposing 'initial' state
|
|
559
773
|
const transformedInitial = transformSVGPathProperties(
|
|
560
774
|
element!,
|
|
@@ -598,11 +812,29 @@
|
|
|
598
812
|
if (isPlaywright) {
|
|
599
813
|
await sleep(10)
|
|
600
814
|
}
|
|
601
|
-
|
|
815
|
+
pwLog('[motion] RAF: promoting to ready and running animation')
|
|
816
|
+
|
|
817
|
+
// Mark that we're triggering the initial animation to prevent duplicate runs
|
|
818
|
+
initialAnimationTriggered = true
|
|
602
819
|
|
|
820
|
+
// IMPORTANT: Start the animation BEFORE changing isLoaded.
|
|
821
|
+
// When isLoaded changes to 'ready', Svelte will reactively remove the
|
|
822
|
+
// initial inline styles. We need the animation to capture the current
|
|
823
|
+
// state (from inline styles) before they're removed.
|
|
603
824
|
runAnimation()
|
|
825
|
+
|
|
826
|
+
// CRITICAL: Wait for the next animation frame before changing isLoaded.
|
|
827
|
+
// This gives WAAPI time to:
|
|
828
|
+
// 1. Parse and create the animation
|
|
829
|
+
// 2. Start the animation layer
|
|
830
|
+
// 3. Lock in the "from" values from current computed style
|
|
831
|
+
// Only THEN can we safely clear inline styles without killing the animation
|
|
832
|
+
requestAnimationFrame(() => {
|
|
833
|
+
isLoaded = 'ready'
|
|
834
|
+
})
|
|
604
835
|
})
|
|
605
836
|
} else {
|
|
837
|
+
pwLog('[motion] path: no initialKeyframes, skip to ready')
|
|
606
838
|
dataPath = 2
|
|
607
839
|
isLoaded = 'ready'
|
|
608
840
|
// If we're inheriting a variant and parent had initial={false}, apply the variant instantly
|
|
@@ -644,7 +876,15 @@
|
|
|
644
876
|
</script>
|
|
645
877
|
|
|
646
878
|
{#if isVoidTag}
|
|
647
|
-
|
|
879
|
+
{#if isSVGTag(String(tag))}
|
|
880
|
+
<svelte:element this={tag} bind:this={element} xmlns={SVG_NAMESPACE} {...derivedAttrs} />
|
|
881
|
+
{:else}
|
|
882
|
+
<svelte:element this={tag} bind:this={element} {...derivedAttrs} />
|
|
883
|
+
{/if}
|
|
884
|
+
{:else if isSVGTag(String(tag))}
|
|
885
|
+
<svelte:element this={tag} bind:this={element} xmlns={SVG_NAMESPACE} {...derivedAttrs}>
|
|
886
|
+
{@render children?.()}
|
|
887
|
+
</svelte:element>
|
|
648
888
|
{:else}
|
|
649
889
|
<svelte:element this={tag} bind:this={element} {...derivedAttrs}>
|
|
650
890
|
{@render children?.()}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,11 +3,15 @@ import MotionConfig from './components/MotionConfig.svelte';
|
|
|
3
3
|
import type { MotionComponents } from './html/index';
|
|
4
4
|
export declare const motion: MotionComponents;
|
|
5
5
|
export { animate, hover } from 'motion';
|
|
6
|
-
export type { DragAxis, DragConstraints, DragControls, DragInfo, DragTransition, MotionAnimate, MotionInitial, MotionOnDirectionLock, MotionOnDragTransitionEnd, MotionTransition, MotionWhileDrag, MotionWhileFocus, MotionWhileHover, MotionWhileTap, Variants } from './types';
|
|
6
|
+
export type { DragAxis, DragConstraints, DragControls, DragInfo, DragTransition, MotionAnimate, MotionInitial, MotionOnDirectionLock, MotionOnDragTransitionEnd, MotionOnInViewEnd, MotionOnInViewStart, MotionTransition, MotionWhileDrag, MotionWhileFocus, MotionWhileHover, MotionWhileInView, MotionWhileTap, Variants } from './types';
|
|
7
7
|
export { useAnimationFrame } from './utils/animationFrame';
|
|
8
8
|
export { createDragControls } from './utils/dragControls';
|
|
9
9
|
export { useSpring } from './utils/spring';
|
|
10
|
+
/**
|
|
11
|
+
* @deprecated Use `styleString` instead for reactive styles with automatic unit handling.
|
|
12
|
+
*/
|
|
10
13
|
export { stringifyStyleObject } from './utils/styleObject';
|
|
14
|
+
export { styleString } from './utils/styleObject.svelte';
|
|
11
15
|
export { useTime } from './utils/time';
|
|
12
16
|
export { useTransform } from './utils/transform';
|
|
13
17
|
export { AnimatePresence, MotionConfig };
|
package/dist/index.js
CHANGED
|
@@ -8,7 +8,11 @@ export { animate, hover } from 'motion';
|
|
|
8
8
|
export { useAnimationFrame } from './utils/animationFrame';
|
|
9
9
|
export { createDragControls } from './utils/dragControls';
|
|
10
10
|
export { useSpring } from './utils/spring';
|
|
11
|
+
/**
|
|
12
|
+
* @deprecated Use `styleString` instead for reactive styles with automatic unit handling.
|
|
13
|
+
*/
|
|
11
14
|
export { stringifyStyleObject } from './utils/styleObject';
|
|
15
|
+
export { styleString } from './utils/styleObject.svelte';
|
|
12
16
|
export { useTime } from './utils/time';
|
|
13
17
|
export { useTransform } from './utils/transform';
|
|
14
18
|
export { AnimatePresence, MotionConfig };
|
package/dist/types.d.ts
CHANGED
|
@@ -121,6 +121,18 @@ export type MotionWhileFocus = (Record<string, unknown> & {
|
|
|
121
121
|
export type MotionWhileDrag = (Record<string, unknown> & {
|
|
122
122
|
transition?: AnimationOptions;
|
|
123
123
|
}) | DOMKeyframesDefinition | undefined;
|
|
124
|
+
/**
|
|
125
|
+
* Animation properties for in-view interactions.
|
|
126
|
+
* When the element enters the viewport, it animates to this state; when it leaves,
|
|
127
|
+
* it animates back to its baseline (from animate/initial), restoring only the changed keys.
|
|
128
|
+
* @example
|
|
129
|
+
* ```svelte
|
|
130
|
+
* <motion.div whileInView={{ opacity: 1, y: 0 }} />
|
|
131
|
+
* ```
|
|
132
|
+
*/
|
|
133
|
+
export type MotionWhileInView = (Record<string, unknown> & {
|
|
134
|
+
transition?: AnimationOptions;
|
|
135
|
+
}) | DOMKeyframesDefinition | undefined;
|
|
124
136
|
/**
|
|
125
137
|
* Animation transition configuration for hover interactions.
|
|
126
138
|
* Overrides the global transition when provided.
|
|
@@ -136,6 +148,9 @@ export type MotionOnHoverEnd = (() => void) | undefined;
|
|
|
136
148
|
/** Focus lifecycle callbacks */
|
|
137
149
|
export type MotionOnFocusStart = (() => void) | undefined;
|
|
138
150
|
export type MotionOnFocusEnd = (() => void) | undefined;
|
|
151
|
+
/** InView lifecycle callbacks */
|
|
152
|
+
export type MotionOnInViewStart = (() => void) | undefined;
|
|
153
|
+
export type MotionOnInViewEnd = (() => void) | undefined;
|
|
139
154
|
/** Tap lifecycle callbacks */
|
|
140
155
|
export type MotionOnTapStart = (() => void) | undefined;
|
|
141
156
|
export type MotionOnTap = (() => void) | undefined;
|
|
@@ -190,6 +205,21 @@ export type DragControls = {
|
|
|
190
205
|
* Base motion props shared by all motion components.
|
|
191
206
|
*/
|
|
192
207
|
export type MotionProps = {
|
|
208
|
+
/**
|
|
209
|
+
* Unique key for AnimatePresence tracking.
|
|
210
|
+
* Required when inside an AnimatePresence component.
|
|
211
|
+
* Used to track enter/exit state and determine whether to animate.
|
|
212
|
+
*
|
|
213
|
+
* @example
|
|
214
|
+
* ```svelte
|
|
215
|
+
* <AnimatePresence>
|
|
216
|
+
* {#if isVisible}
|
|
217
|
+
* <motion.div key="box" exit={{ opacity: 0 }} />
|
|
218
|
+
* {/if}
|
|
219
|
+
* </AnimatePresence>
|
|
220
|
+
* ```
|
|
221
|
+
*/
|
|
222
|
+
key?: string;
|
|
193
223
|
/** Variants define named animation states */
|
|
194
224
|
variants?: Variants;
|
|
195
225
|
/** Initial state of the animation (object or variant key) */
|
|
@@ -208,6 +238,8 @@ export type MotionProps = {
|
|
|
208
238
|
whileFocus?: MotionWhileFocus;
|
|
209
239
|
/** Drag interaction animation */
|
|
210
240
|
whileDrag?: MotionWhileDrag;
|
|
241
|
+
/** In-view interaction animation - animates when element enters viewport */
|
|
242
|
+
whileInView?: MotionWhileInView;
|
|
211
243
|
/** Called right before a main animate transition starts */
|
|
212
244
|
onAnimationStart?: MotionAnimationStart;
|
|
213
245
|
/** Called after a main animate transition completes */
|
|
@@ -220,6 +252,10 @@ export type MotionProps = {
|
|
|
220
252
|
onFocusStart?: MotionOnFocusStart;
|
|
221
253
|
/** Called when element loses keyboard focus */
|
|
222
254
|
onFocusEnd?: MotionOnFocusEnd;
|
|
255
|
+
/** Called when element enters viewport */
|
|
256
|
+
onInViewStart?: MotionOnInViewStart;
|
|
257
|
+
/** Called when element leaves viewport */
|
|
258
|
+
onInViewEnd?: MotionOnInViewEnd;
|
|
223
259
|
/** Called when a tap gesture starts (pointerdown recognized) */
|
|
224
260
|
onTapStart?: MotionOnTapStart;
|
|
225
261
|
/** Called when a tap gesture ends successfully (pointerup) */
|
package/dist/utils/animation.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { pwLog } from './log';
|
|
1
2
|
import { hasFinishedPromise, isPromiseLike } from './promise';
|
|
2
3
|
import { animate } from 'motion';
|
|
3
4
|
/**
|
|
@@ -32,12 +33,17 @@ export const mergeTransitions = (...args) => {
|
|
|
32
33
|
* @param onStart Optional lifecycle fired before animation starts.
|
|
33
34
|
* @param onComplete Optional lifecycle fired after animation completes.
|
|
34
35
|
*/
|
|
35
|
-
export const animateWithLifecycle = (el, keyframes, transition,
|
|
36
|
-
/* trunk-ignore(eslint/no-unused-vars) */
|
|
37
|
-
onStart,
|
|
38
|
-
/* trunk-ignore(eslint/no-unused-vars) */
|
|
39
|
-
onComplete) => {
|
|
36
|
+
export const animateWithLifecycle = (el, keyframes, transition, onStart, onComplete) => {
|
|
40
37
|
const payload = keyframes;
|
|
38
|
+
const computed = getComputedStyle(el);
|
|
39
|
+
pwLog('[animateWithLifecycle] starting', {
|
|
40
|
+
keyframes: payload,
|
|
41
|
+
transition,
|
|
42
|
+
currentOpacity: el.style.opacity,
|
|
43
|
+
currentTransform: el.style.transform,
|
|
44
|
+
computedOpacity: computed.opacity,
|
|
45
|
+
computedTransform: computed.transform
|
|
46
|
+
});
|
|
41
47
|
onStart?.(payload);
|
|
42
48
|
const controls = animate(el, payload, transition);
|
|
43
49
|
if (hasFinishedPromise(controls)) {
|