@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 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/motion/animate-presence` | [View Example](https://svelte.dev/playground/ef277e283d864653ace54e7453801601?version=5.38.10) |
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 { createAnimatePresenceContext, setAnimatePresenceContext } from '../utils/presence'
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 { children, onExitComplete } = $props<{
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
- const context = createAnimatePresenceContext({ onExitComplete })
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
- position: relative;
46
+ display: contents;
31
47
  }
32
48
  </style>
@@ -1,6 +1,7 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  type $$ComponentProps = {
3
3
  children?: Snippet;
4
+ initial?: boolean;
4
5
  onExitComplete?: () => void;
5
6
  };
6
7
  declare const AnimatePresence: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -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 { usePresence, getAnimatePresenceContext } from '../utils/presence'
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 { transformSVGPathProperties, computeNormalizedSVGInitialAttrs } from '../utils/svg'
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
- // Generate unique key for presence tracking
99
- const presenceKey = `motion-${Math.random().toString(36).slice(2)}`
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 effectiveInitialProp =
222
- initialProp !== undefined
223
- ? initialProp
224
- : parentInitialFalse && variantsProp
225
- ? false
226
- : undefined
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 unknown as Record<string, unknown>,
283
- resolvedAnimate as unknown as Record<string, unknown>
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
- if (!element || !resolvedAnimate) return
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 - always run
510
- lastRanVariantKey = undefined
511
- runAnimation()
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
- isLoaded = 'ready'
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
- <svelte:element this={tag} bind:this={element} {...derivedAttrs} />
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) */
@@ -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)) {