@humanspeak/svelte-motion 0.5.4 → 0.6.1

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.
@@ -14,6 +14,8 @@
14
14
  import type {
15
15
  MotionProps,
16
16
  MotionTransition,
17
+ AnimationControlsDefinition,
18
+ AnimationControlsSubscriber,
17
19
  DragAxis,
18
20
  DragConstraints,
19
21
  DragControls,
@@ -28,10 +30,12 @@
28
30
  import { isNotEmpty } from '../utils/objects'
29
31
  import { sleep } from '../utils/testing'
30
32
  import { animate, type AnimationOptions, type DOMKeyframesDefinition } from 'motion'
33
+ import { motionValue, svgEffect, type MotionValue } from 'motion-dom'
31
34
  import { isPlaywrightEnv, pwLog } from '../utils/log'
32
35
  import { onDestroy, untrack, type Snippet } from 'svelte'
33
36
  import { VOID_TAGS } from '../utils/constants'
34
37
  import { mergeTransitions, animateWithLifecycle } from '../utils/animation'
38
+ import { isAnimationControls } from '../utils/animationControls.svelte'
35
39
  import { attachWhileTap } from '../utils/interaction'
36
40
  import { attachWhileHover, computeHoverBaseline, splitHoverDefinition } from '../utils/hover'
37
41
  import { attachWhileFocus } from '../utils/focus'
@@ -58,11 +62,18 @@
58
62
  import { attachPan, type AttachPanCleanup } from '../utils/pan'
59
63
  import { ProjectionNode } from '../utils/projection'
60
64
  import { getProjectionParent, setProjectionParent } from '../components/projection.context'
65
+ import { MotionDomProjectionAdapter } from '../utils/motionDomProjection'
66
+ import { SvelteSet } from 'svelte/reactivity'
67
+ import {
68
+ getMotionDomProjectionParent,
69
+ setMotionDomProjectionParent
70
+ } from '../components/motionDomProjection.context'
61
71
  import {
62
72
  resolveInitial,
63
73
  resolveAnimate,
64
74
  resolveExit,
65
75
  resolveWhile,
76
+ resolveVariantList,
66
77
  resolveRestingValues
67
78
  } from '../utils/variants'
68
79
  import {
@@ -77,9 +88,19 @@
77
88
  import {
78
89
  transformSVGPathProperties,
79
90
  computeNormalizedSVGInitialAttrs,
91
+ hasSVGPathProperties,
92
+ isSVGPathElement,
80
93
  isSVGTag,
81
94
  SVG_NAMESPACE
82
95
  } from '../utils/svg'
96
+ import {
97
+ createOptimizedAppearData,
98
+ createOptimizedAppearScript,
99
+ finishOptimizedAppearAnimation,
100
+ hasOptimizedAppearAnimation,
101
+ markMotionMounted,
102
+ optimizedAppearDataAttribute
103
+ } from '../utils/optimizedAppear'
83
104
  import { getLayoutIdRegistry } from '../utils/layoutId'
84
105
  import {
85
106
  getLayoutScrollContainerRef,
@@ -93,6 +114,8 @@
93
114
  [key: string]: unknown
94
115
  }
95
116
 
117
+ const componentHydrationId = $props.id()
118
+
96
119
  let {
97
120
  children,
98
121
  tag = 'div',
@@ -234,6 +257,18 @@
234
257
  })
235
258
  setProjectionParent(projection)
236
259
 
260
+ const motionDomProjectionParent =
261
+ typeof window !== 'undefined' ? getMotionDomProjectionParent() : null
262
+ const motionDomProjection =
263
+ typeof window !== 'undefined'
264
+ ? new MotionDomProjectionAdapter({
265
+ parent: motionDomProjectionParent
266
+ })
267
+ : null
268
+ if (motionDomProjection) {
269
+ setMotionDomProjectionParent(motionDomProjection)
270
+ }
271
+
237
272
  // Convert a projection `Box` (ancestor chain reset to base, self
238
273
  // transform stripped, scroll containers compensated) to the
239
274
  // `RectLike` shape `computeFlipTransforms` consumes.
@@ -246,9 +281,25 @@
246
281
  width: box.x.max - box.x.min,
247
282
  height: box.y.max - box.y.min
248
283
  })
284
+ const domRectToRectLike = (rect: DOMRect): RectLike => ({
285
+ left: rect.left,
286
+ top: rect.top,
287
+ width: rect.width,
288
+ height: rect.height
289
+ })
290
+ const hasRectChanged = (previous: RectLike, next: RectLike): boolean =>
291
+ Math.abs(previous.left - next.left) > 0.5 ||
292
+ Math.abs(previous.top - next.top) > 0.5 ||
293
+ Math.abs(previous.width - next.width) > 0.5 ||
294
+ Math.abs(previous.height - next.height) > 0.5
295
+ const isViewportOffscreen = (rect: DOMRect): boolean =>
296
+ rect.bottom <= 0 ||
297
+ rect.right <= 0 ||
298
+ rect.top >= window.innerHeight ||
299
+ rect.left >= window.innerWidth
249
300
 
250
301
  // Ancestor-transform-invariant layout measurement for seeding the
251
- // FLIP effect's first rect.
302
+ // fallback FLIP effect's first rect.
252
303
  const measureLayoutRect = (): RectLike | null => {
253
304
  const box = projection.measure()
254
305
  return box ? boxToRectLike(box) : null
@@ -422,6 +473,8 @@
422
473
 
423
474
  // Variant inheritance and resolution
424
475
  const parentVariantStore = getVariantContext()
476
+ const animateControls = $derived(isAnimationControls(animateProp) ? animateProp : undefined)
477
+ const declarativeAnimateProp = $derived(animateControls ? undefined : animateProp)
425
478
 
426
479
  // Get initial inherited variant synchronously
427
480
  let initialInheritedVariant: string | undefined = undefined
@@ -431,8 +484,8 @@
431
484
 
432
485
  // Create store with initial value so children can inherit immediately
433
486
  const initialVariantValue =
434
- typeof animateProp === 'string'
435
- ? animateProp
487
+ typeof declarativeAnimateProp === 'string'
488
+ ? declarativeAnimateProp
436
489
  : (variantsProp && initialInheritedVariant) || undefined
437
490
  const localVariantStore = writable<string | undefined>(initialVariantValue)
438
491
 
@@ -449,7 +502,8 @@
449
502
 
450
503
  // Use the initial value first, then switch to reactive once mounted
451
504
  const effectiveAnimate = $derived(
452
- animateProp ?? (variantsProp ? (inheritedVariant ?? initialInheritedVariant) : undefined)
505
+ declarativeAnimateProp ??
506
+ (variantsProp ? (inheritedVariant ?? initialInheritedVariant) : undefined)
453
507
  )
454
508
 
455
509
  // Propagate initial={false} to children BEFORE setting variant context
@@ -506,7 +560,8 @@
506
560
 
507
561
  $effect(() => {
508
562
  if (!variantsProp) return localVariantStore.set(undefined)
509
- if (typeof animateProp === 'string') return localVariantStore.set(animateProp)
563
+ if (typeof declarativeAnimateProp === 'string')
564
+ return localVariantStore.set(declarativeAnimateProp)
510
565
  if (typeof effectiveAnimate === 'string') return localVariantStore.set(effectiveAnimate)
511
566
  localVariantStore.set(undefined)
512
567
  })
@@ -551,6 +606,338 @@
551
606
  reducedMotion
552
607
  )
553
608
  )
609
+ const optimizedAppearId = $derived(
610
+ effectiveInitialProp !== false &&
611
+ isNotEmpty(initialKeyframes) &&
612
+ isNotEmpty(animateKeyframes)
613
+ ? `svelte-motion-${componentHydrationId}`
614
+ : undefined
615
+ )
616
+ const optimizedAppearEntries = $derived(
617
+ createOptimizedAppearData(
618
+ initialKeyframes as Record<string, unknown> | undefined,
619
+ animateKeyframes as Record<string, unknown> | undefined,
620
+ mergedTransition
621
+ )
622
+ )
623
+ const optimizedAppearScript = $derived(
624
+ createOptimizedAppearScript(optimizedAppearId, optimizedAppearEntries)
625
+ )
626
+ const renderedOptimizedAppearScript = $derived(
627
+ optimizedAppearScript && (typeof window === 'undefined' || !window.MotionIsMounted)
628
+ ? optimizedAppearScript
629
+ : ''
630
+ )
631
+ const applyAnimateRestingStyle = () => {
632
+ if (!element) return
633
+ const restingValues = resolveRestingValues(
634
+ animateKeyframes as DOMKeyframesDefinition | undefined
635
+ ) as Record<string, unknown> | undefined
636
+ if (!restingValues) return
637
+ element.setAttribute(
638
+ 'style',
639
+ mergeInlineStyles(element.getAttribute('style') ?? '', undefined, restingValues)
640
+ )
641
+ }
642
+ const isJsdomRuntime = (): boolean =>
643
+ typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent)
644
+ const getTransitionFallbackMs = (transition: AnimationOptions | undefined): number => {
645
+ const duration = typeof transition?.duration === 'number' ? transition.duration : 0
646
+ const delay = typeof transition?.delay === 'number' ? transition.delay : 0
647
+ return Math.max(0, (duration + delay) * 1000)
648
+ }
649
+ let cleanupSVGPathAttributeEffect: (() => void) | null = null
650
+
651
+ /**
652
+ * Reads the current normalized SVG path drawing state from DOM
653
+ * attributes. `motion-dom`'s svgEffect owns future writes; this only
654
+ * seeds its MotionValues from the currently rendered frame.
655
+ *
656
+ * @param {SVGPathElement} path The SVG path element to inspect.
657
+ * @returns {{ pathLength: number; pathSpacing: number; pathOffset: number }} The normalized drawing state.
658
+ */
659
+ const readSVGPathDrawingState = (
660
+ path: SVGPathElement
661
+ ): { pathLength: number; pathSpacing: number; pathOffset: number } => {
662
+ const dashArray =
663
+ path.getAttribute('stroke-dasharray') || path.style.strokeDasharray || '1 0'
664
+ const [rawLength, rawSpacing] = dashArray
665
+ .split(/[,\s]+/)
666
+ .filter(Boolean)
667
+ .map((part) => Number.parseFloat(part))
668
+ const rawOffset = Number.parseFloat(
669
+ path.getAttribute('stroke-dashoffset') || path.style.strokeDashoffset || '0'
670
+ )
671
+
672
+ return {
673
+ pathLength: Number.isFinite(rawLength) ? rawLength : 1,
674
+ pathSpacing: Number.isFinite(rawSpacing) ? rawSpacing : 1,
675
+ pathOffset: Number.isFinite(rawOffset) ? -rawOffset : 0
676
+ }
677
+ }
678
+
679
+ /**
680
+ * Removes custom SVG path props from keyframes after `svgEffect` has
681
+ * taken ownership of them.
682
+ *
683
+ * @param {Record<string, unknown>} keyframes Keyframes to copy.
684
+ * @returns {Record<string, unknown>} Keyframes without SVG path-only props.
685
+ */
686
+ const stripSVGPathKeyframes = (keyframes: Record<string, unknown>): Record<string, unknown> => {
687
+ const stripped = { ...keyframes }
688
+ delete stripped.pathLength
689
+ delete stripped.pathSpacing
690
+ delete stripped.pathOffset
691
+ return stripped
692
+ }
693
+
694
+ /**
695
+ * Extracts an animation completion promise from a Motion control when
696
+ * one is available.
697
+ *
698
+ * @param {unknown} control The return value from `animate`.
699
+ * @returns {Promise<unknown> | null} The finished promise, or null.
700
+ */
701
+ const getFinishedPromise = (control: unknown): Promise<unknown> | null => {
702
+ if (!control || typeof control !== 'object') return null
703
+ const finished = (control as { finished?: unknown }).finished
704
+ return finished && typeof (finished as Promise<unknown>).then === 'function'
705
+ ? (finished as Promise<unknown>)
706
+ : null
707
+ }
708
+
709
+ const getAnimationPromise = (control: unknown): Promise<unknown> => {
710
+ const finished = getFinishedPromise(control)
711
+ if (finished) return finished
712
+ if (control && typeof (control as Promise<unknown>).then === 'function') {
713
+ return control as Promise<unknown>
714
+ }
715
+ return Promise.resolve()
716
+ }
717
+
718
+ type StoppableAnimationControl = {
719
+ stop?: () => void
720
+ cancel?: () => void
721
+ }
722
+
723
+ const activeAnimationControls = new SvelteSet<StoppableAnimationControl>()
724
+ let animationControlsGeneration = 0
725
+ let animationControlsHasReceivedCommand = false
726
+
727
+ const isStoppableAnimationControl = (control: unknown): control is StoppableAnimationControl =>
728
+ !!control &&
729
+ typeof control === 'object' &&
730
+ (typeof (control as StoppableAnimationControl).stop === 'function' ||
731
+ typeof (control as StoppableAnimationControl).cancel === 'function')
732
+
733
+ const trackAnimationControlsControl = (control: unknown): Promise<unknown> => {
734
+ const promise = getAnimationPromise(control)
735
+ if (isStoppableAnimationControl(control)) {
736
+ activeAnimationControls.add(control)
737
+ promise.then(
738
+ () => activeAnimationControls.delete(control),
739
+ () => activeAnimationControls.delete(control)
740
+ )
741
+ }
742
+ return promise
743
+ }
744
+
745
+ const resolveAnimationControlsDefinition = (
746
+ definition: AnimationControlsDefinition
747
+ ): DOMKeyframesDefinition | undefined => {
748
+ const resolvedDefinition =
749
+ typeof definition === 'function' ? definition(effectiveCustom) : definition
750
+ if (typeof resolvedDefinition === 'string' || Array.isArray(resolvedDefinition)) {
751
+ return resolveVariantList(variantsProp, resolvedDefinition, effectiveCustom)
752
+ }
753
+ return resolvedDefinition
754
+ }
755
+
756
+ const applyAnimationControlsTarget = (definition: AnimationControlsDefinition) => {
757
+ if (!element) return
758
+ const resolved = resolveAnimationControlsDefinition(definition)
759
+ if (!resolved) return
760
+
761
+ animationControlsHasReceivedCommand = true
762
+ const target = { ...(resolved as Record<string, unknown>) } as Record<string, unknown> & {
763
+ transition?: AnimationOptions
764
+ transitionEnd?: Record<string, unknown>
765
+ }
766
+ const transitionEnd = target.transitionEnd
767
+ delete target.transition
768
+ delete target.transitionEnd
769
+ const finalTarget = resolveRestingValues({
770
+ ...target,
771
+ ...(transitionEnd ?? {})
772
+ } as DOMKeyframesDefinition) as Record<string, unknown> | undefined
773
+ if (!finalTarget) return
774
+
775
+ const transformedTarget = transformSVGPathProperties(element, finalTarget) as Record<
776
+ string,
777
+ unknown
778
+ >
779
+ animate(element, transformedTarget as DOMKeyframesDefinition, { duration: 0 })
780
+ element.setAttribute(
781
+ 'style',
782
+ mergeInlineStyles(element.getAttribute('style') ?? '', undefined, transformedTarget)
783
+ )
784
+ enterAnimationSettled = true
785
+ }
786
+
787
+ const stopAnimationControlsAnimations = () => {
788
+ animationControlsHasReceivedCommand = true
789
+ animationControlsGeneration += 1
790
+
791
+ for (const control of activeAnimationControls) {
792
+ if (typeof control.stop === 'function') {
793
+ control.stop()
794
+ } else {
795
+ control.cancel?.()
796
+ }
797
+ }
798
+ activeAnimationControls.clear()
799
+
800
+ if (!element) return
801
+ if (typeof element.getAnimations !== 'function') return
802
+ for (const animation of element.getAnimations()) {
803
+ try {
804
+ animation.commitStyles?.()
805
+ } catch {
806
+ // Ignore unsupported commitStyles cases.
807
+ }
808
+ animation.cancel()
809
+ }
810
+ }
811
+
812
+ const startAnimationControlsDefinition = async (
813
+ definition: AnimationControlsDefinition,
814
+ transitionOverride?: AnimationOptions
815
+ ): Promise<unknown> => {
816
+ if (!element) return
817
+ const resolved = resolveAnimationControlsDefinition(definition)
818
+ if (!resolved) return
819
+
820
+ animationControlsHasReceivedCommand = true
821
+ const filtered = filterReducedMotionKeyframes(
822
+ resolved as Record<string, unknown>,
823
+ reducedMotion
824
+ ) as Record<string, unknown> & {
825
+ transition?: AnimationOptions
826
+ transitionEnd?: Record<string, unknown>
827
+ }
828
+ const transition = filtered.transition
829
+ const target = { ...filtered }
830
+ delete target.transition
831
+ delete target.transitionEnd
832
+ const transitionAnimate: MotionTransition =
833
+ transitionOverride ?? mergeTransitions(mergedTransition ?? {}, transition ?? {})
834
+ const svgPathFinished =
835
+ isSVGPathElement(element) && hasSVGPathProperties(target)
836
+ ? animateSVGPathAttributes(element, target, transitionAnimate, true)
837
+ : []
838
+ const payload = transformSVGPathProperties(
839
+ element,
840
+ svgPathFinished.length > 0 ? stripSVGPathKeyframes(target) : target
841
+ ) as Record<string, unknown>
842
+
843
+ const controlsGeneration = ++animationControlsGeneration
844
+ enterAnimationSettled = false
845
+ onAnimationStartProp?.(definition as unknown as DOMKeyframesDefinition)
846
+
847
+ const promises: Promise<unknown>[] = [...svgPathFinished]
848
+ if (isNotEmpty(payload)) {
849
+ promises.push(
850
+ trackAnimationControlsControl(
851
+ animate(
852
+ element,
853
+ payload as DOMKeyframesDefinition,
854
+ transitionAnimate as AnimationOptions
855
+ )
856
+ )
857
+ )
858
+ }
859
+
860
+ try {
861
+ await Promise.all(promises)
862
+ } catch (error) {
863
+ if (controlsGeneration !== animationControlsGeneration) return
864
+ throw error
865
+ }
866
+ if (controlsGeneration !== animationControlsGeneration) return
867
+ applyAnimationControlsTarget(definition)
868
+ onAnimationCompleteProp?.(definition as unknown as DOMKeyframesDefinition)
869
+ }
870
+
871
+ /**
872
+ * Animates SVG path drawing props via motion-dom's `svgEffect`, matching
873
+ * upstream's attribute-based pathLength/pathSpacing/pathOffset behavior.
874
+ *
875
+ * @param {SVGPathElement} path The path element to animate.
876
+ * @param {Record<string, unknown>} keyframes Keyframes containing SVG path props.
877
+ * @param {MotionTransition} transition The transition to apply to generated MotionValues.
878
+ * @returns {Promise<unknown>[]} Promises for generated path animations.
879
+ */
880
+ const animateSVGPathAttributes = (
881
+ path: SVGPathElement,
882
+ keyframes: Record<string, unknown>,
883
+ transition: MotionTransition,
884
+ trackControl = false
885
+ ): Promise<unknown>[] => {
886
+ if (!hasSVGPathProperties(keyframes)) return []
887
+
888
+ cleanupSVGPathAttributeEffect?.()
889
+ const current = readSVGPathDrawingState(path)
890
+ const values: Record<string, MotionValue<number>> = {}
891
+
892
+ if ('pathLength' in keyframes) {
893
+ values.pathLength = motionValue(current.pathLength)
894
+ }
895
+ if ('pathLength' in keyframes || 'pathSpacing' in keyframes) {
896
+ values.pathSpacing = motionValue(current.pathSpacing)
897
+ }
898
+ if ('pathOffset' in keyframes) {
899
+ values.pathOffset = motionValue(current.pathOffset)
900
+ }
901
+
902
+ cleanupSVGPathAttributeEffect = svgEffect(path, values)
903
+
904
+ return Object.entries(values)
905
+ .map(([key, value]) => {
906
+ const control = animate(
907
+ value as never,
908
+ (key === 'pathSpacing' && !('pathSpacing' in keyframes)
909
+ ? 1
910
+ : keyframes[key]) as never,
911
+ transition as unknown as AnimationOptions
912
+ )
913
+ return trackControl
914
+ ? trackAnimationControlsControl(control)
915
+ : getFinishedPromise(control)
916
+ })
917
+ .filter((promise): promise is Promise<unknown> => promise !== null)
918
+ }
919
+
920
+ onDestroy(() => {
921
+ cleanupSVGPathAttributeEffect?.()
922
+ cleanupSVGPathAttributeEffect = null
923
+ })
924
+
925
+ // Wait-mode enter coordination needs to affect the first rendered attrs,
926
+ // before the blocked entrant can participate in layout.
927
+ let waitCallbackRegistered = $state(false)
928
+ let waitUnsubscribe: (() => void) | null = null
929
+ let waitHiddenDisplay: string | null = null
930
+ let waitEnterReleased = $state(false)
931
+ let waitLayoutParent: HTMLElement | null = null
932
+ let waitLayoutParentWidth = ''
933
+ let waitLayoutParentHeight = ''
934
+ let waitLayoutViewportScrollX = 0
935
+ let waitLayoutViewportScrollY = 0
936
+ const presenceLayoutHoldAttribute = 'data-presence-layout-hold'
937
+ const presenceLayoutReleaseEvent = 'svelte-motion:presence-layout-release'
938
+ const waitEnterBlockedBeforeMount = $derived(
939
+ context?.mode === 'wait' && !waitEnterReleased && context.isEnterBlocked(presenceKey)
940
+ )
554
941
 
555
942
  // Derived attributes to keep both branches in sync (focusability, data flags, style, class)
556
943
  const derivedAttrs = $derived<Record<string, unknown>>({
@@ -575,6 +962,16 @@
575
962
  'data-path': dataPath
576
963
  }
577
964
  : {}),
965
+ ...(renderedOptimizedAppearScript
966
+ ? { [optimizedAppearDataAttribute]: optimizedAppearId }
967
+ : {}),
968
+ ...(layoutProp
969
+ ? { 'data-layout': String(layoutProp), 'data-svelte-motion-layout': '' }
970
+ : {}),
971
+ ...(scopedLayoutId ? { 'data-layout-id': scopedLayoutId } : {}),
972
+ ...(waitEnterBlockedBeforeMount || waitHiddenDisplay !== null
973
+ ? { 'data-presence-wait-hidden': 'true' }
974
+ : {}),
578
975
  // Apply normalized SVG path attributes synchronously on first render to avoid flash
579
976
  // Compute via svg utils (no dynamic import in SSR/derived expressions)
580
977
  ...(() => {
@@ -588,9 +985,7 @@
588
985
  return {}
589
986
  })(),
590
987
  style: mergeInlineStyles(
591
- initialKeyframes && 'pathLength' in initialKeyframes && isLoaded === 'mounting'
592
- ? `${styleProp || ''};visibility:hidden`
593
- : styleProp,
988
+ `${initialKeyframes && 'pathLength' in initialKeyframes && isLoaded === 'mounting' ? `${styleProp || ''};visibility:hidden` : (styleProp ?? '')}${waitEnterBlockedBeforeMount || waitHiddenDisplay !== null ? ';display:none' : ''}`,
594
989
  // The "from" slot: apply initialKeyframes as inline styles during
595
990
  // the mounting/initial phases (before the WAAPI animation locks
596
991
  // its from-value and we promote to 'ready' — see the lifecycle
@@ -615,9 +1010,13 @@
615
1010
  ? (resolveRestingValues(
616
1011
  animateKeyframes as DOMKeyframesDefinition | undefined
617
1012
  ) as unknown as Record<string, unknown>)
618
- : isNotEmpty(initialKeyframes)
619
- ? undefined
620
- : (animateKeyframes as unknown as Record<string, unknown>)
1013
+ : animateControls &&
1014
+ !animationControlsHasReceivedCommand &&
1015
+ isNotEmpty(initialKeyframes)
1016
+ ? (initialKeyframes as unknown as Record<string, unknown>)
1017
+ : isNotEmpty(initialKeyframes)
1018
+ ? undefined
1019
+ : (animateKeyframes as unknown as Record<string, unknown>)
621
1020
  ),
622
1021
  class: classProp
623
1022
  })
@@ -869,7 +1268,17 @@
869
1268
  }
870
1269
 
871
1270
  const transitionAnimate: MotionTransition = mergedTransition ?? {}
872
- let payload = $state.snapshot(resolvedAnimate)
1271
+ const rawPayload = filterReducedMotionKeyframes(
1272
+ $state.snapshot(resolvedAnimate) as Record<string, unknown>,
1273
+ reducedMotion
1274
+ ) as Record<string, unknown>
1275
+ const svgPathFinished =
1276
+ isSVGPathElement(element) && hasSVGPathProperties(rawPayload)
1277
+ ? animateSVGPathAttributes(element, rawPayload, transitionAnimate)
1278
+ : []
1279
+ let payload = (
1280
+ svgPathFinished.length > 0 ? stripSVGPathKeyframes(rawPayload) : rawPayload
1281
+ ) as typeof rawPayload
873
1282
 
874
1283
  // Transform SVG path properties (pathLength, pathOffset) to their CSS equivalents
875
1284
  payload = transformSVGPathProperties(
@@ -877,13 +1286,6 @@
877
1286
  payload as Record<string, unknown>
878
1287
  ) as typeof payload
879
1288
 
880
- // Strip transform keys when reduced-motion is active so the element
881
- // stays in place while opacity / color etc. still animate.
882
- payload = filterReducedMotionKeyframes(
883
- payload as Record<string, unknown>,
884
- reducedMotion
885
- ) as typeof payload
886
-
887
1289
  // Ensure dash properties aren't pinned as inline styles
888
1290
  if (element && (element as HTMLElement).style) {
889
1291
  ;(element as HTMLElement).style.removeProperty('stroke-dasharray')
@@ -897,33 +1299,134 @@
897
1299
 
898
1300
  // A fresh run owns the transform again until it completes.
899
1301
  enterAnimationSettled = false
900
- animateWithLifecycle(
901
- element,
902
- payload as unknown as DOMKeyframesDefinition,
903
- transitionAnimate as unknown as AnimationOptions,
904
- (def) => onAnimationStartProp?.(def as unknown as DOMKeyframesDefinition | undefined),
905
- (def) => {
906
- // Now the target is the resting state — promote it to the
907
- // inline baseline so it persists after WAAPI surrenders the
908
- // property (default fill:'none'). (#377)
909
- enterAnimationSettled = true
910
- onAnimationCompleteProp?.(def as unknown as DOMKeyframesDefinition | undefined)
911
- }
912
- )
1302
+ const completeEnterAnimation = (
1303
+ def: DOMKeyframesDefinition | undefined = payload as unknown as DOMKeyframesDefinition
1304
+ ) => {
1305
+ if (enterAnimationSettled) return
1306
+ // Now the target is the resting state — promote it to the
1307
+ // inline baseline so it persists after WAAPI surrenders the
1308
+ // property (default fill:'none'). (#377)
1309
+ applyAnimateRestingStyle()
1310
+ enterAnimationSettled = true
1311
+ onAnimationCompleteProp?.(def)
1312
+ }
1313
+ if (isNotEmpty(payload)) {
1314
+ animateWithLifecycle(
1315
+ element,
1316
+ payload as unknown as DOMKeyframesDefinition,
1317
+ transitionAnimate as unknown as AnimationOptions,
1318
+ (def) =>
1319
+ onAnimationStartProp?.(def as unknown as DOMKeyframesDefinition | undefined),
1320
+ (def) =>
1321
+ completeEnterAnimation(def as unknown as DOMKeyframesDefinition | undefined)
1322
+ )
1323
+ } else if (svgPathFinished.length > 0) {
1324
+ onAnimationStartProp?.(rawPayload as unknown as DOMKeyframesDefinition)
1325
+ Promise.all(svgPathFinished)
1326
+ .then(() => completeEnterAnimation(rawPayload as unknown as DOMKeyframesDefinition))
1327
+ .catch(() =>
1328
+ completeEnterAnimation(rawPayload as unknown as DOMKeyframesDefinition)
1329
+ )
1330
+ }
1331
+ if (isJsdomRuntime()) {
1332
+ window.setTimeout(
1333
+ () => completeEnterAnimation(),
1334
+ getTransitionFallbackMs(transitionAnimate as AnimationOptions)
1335
+ )
1336
+ }
913
1337
  }
914
1338
 
915
- // Track if we've already registered a wait callback to prevent duplicates
916
- let waitCallbackRegistered = $state(false)
917
- let waitUnsubscribe: (() => void) | null = null
918
-
919
1339
  // Cleanup wait callback on component unmount to prevent memory leaks
920
1340
  $effect(() => {
921
1341
  return () => {
1342
+ if (element && waitHiddenDisplay !== null) {
1343
+ element.style.display = waitHiddenDisplay
1344
+ element.removeAttribute('data-presence-wait-hidden')
1345
+ waitHiddenDisplay = null
1346
+ }
1347
+ releaseWaitLayoutHold()
922
1348
  waitUnsubscribe?.()
923
1349
  waitUnsubscribe = null
924
1350
  }
925
1351
  })
926
1352
 
1353
+ const getPresenceLayoutParent = (): HTMLElement | null => {
1354
+ let parent = element?.parentElement ?? null
1355
+ const layoutParent = element?.parentElement?.closest<HTMLElement>(
1356
+ '[data-svelte-motion-layout]'
1357
+ )
1358
+ if (layoutParent) return layoutParent
1359
+
1360
+ while (parent && getComputedStyle(parent).display === 'contents') {
1361
+ parent = parent.parentElement
1362
+ }
1363
+ return parent
1364
+ }
1365
+
1366
+ const holdWaitLayout = () => {
1367
+ if (!element || waitLayoutParent) return
1368
+ const parent = getPresenceLayoutParent()
1369
+ if (!parent) return
1370
+
1371
+ const rect = parent.getBoundingClientRect()
1372
+ waitLayoutParent = parent
1373
+ waitLayoutParentWidth = parent.style.width
1374
+ waitLayoutParentHeight = parent.style.height
1375
+ waitLayoutViewportScrollX = typeof window !== 'undefined' ? window.scrollX : 0
1376
+ waitLayoutViewportScrollY = typeof window !== 'undefined' ? window.scrollY : 0
1377
+ parent.setAttribute(presenceLayoutHoldAttribute, 'true')
1378
+ parent.style.width = `${rect.width}px`
1379
+ parent.style.height = `${rect.height}px`
1380
+ }
1381
+
1382
+ function releaseWaitLayoutHold() {
1383
+ if (!waitLayoutParent) return
1384
+ const parent = waitLayoutParent
1385
+ const previousRect = parent.getBoundingClientRect()
1386
+ parent.removeAttribute(presenceLayoutHoldAttribute)
1387
+ if (waitLayoutParentWidth) {
1388
+ parent.style.width = waitLayoutParentWidth
1389
+ } else {
1390
+ parent.style.removeProperty('width')
1391
+ }
1392
+ if (waitLayoutParentHeight) {
1393
+ parent.style.height = waitLayoutParentHeight
1394
+ } else {
1395
+ parent.style.removeProperty('height')
1396
+ }
1397
+ const viewportScrolledDuringHold =
1398
+ typeof window !== 'undefined' &&
1399
+ (window.scrollX !== waitLayoutViewportScrollX ||
1400
+ window.scrollY !== waitLayoutViewportScrollY)
1401
+ parent.dispatchEvent(
1402
+ new CustomEvent(presenceLayoutReleaseEvent, {
1403
+ detail: {
1404
+ previousRect: domRectToRectLike(previousRect),
1405
+ viewportScrolledDuringHold
1406
+ }
1407
+ })
1408
+ )
1409
+ waitLayoutParent = null
1410
+ waitLayoutParentWidth = ''
1411
+ waitLayoutParentHeight = ''
1412
+ waitLayoutViewportScrollX = 0
1413
+ waitLayoutViewportScrollY = 0
1414
+ }
1415
+
1416
+ const revealWaitHiddenElement = () => {
1417
+ waitEnterReleased = true
1418
+ if (waitHiddenDisplay !== null && element) {
1419
+ if (waitHiddenDisplay) {
1420
+ element.style.display = waitHiddenDisplay
1421
+ } else {
1422
+ element.style.removeProperty('display')
1423
+ }
1424
+ element.removeAttribute('data-presence-wait-hidden')
1425
+ waitHiddenDisplay = null
1426
+ }
1427
+ releaseWaitLayoutHold()
1428
+ }
1429
+
927
1430
  /**
928
1431
  * Run the enter animation, respecting wait mode if inside AnimatePresence.
929
1432
  * Returns true if animation was deferred (wait mode with blocked enters).
@@ -949,12 +1452,21 @@
949
1452
  return true // Still deferred
950
1453
  }
951
1454
 
952
- const blocked = context.isEnterBlocked?.()
1455
+ const blocked = context.isEnterBlocked?.(presenceKey)
953
1456
  pwLog('[motion] runAnimation: wait mode', { blocked })
954
1457
 
955
1458
  if (blocked) {
956
1459
  pwLog('[motion] runAnimation: enters blocked, deferring')
957
1460
 
1461
+ waitEnterReleased = false
1462
+ if (waitHiddenDisplay === null) {
1463
+ waitHiddenDisplay =
1464
+ element.style.display === 'none' ? '' : element.style.display
1465
+ element.style.display = 'none'
1466
+ element.setAttribute('data-presence-wait-hidden', 'true')
1467
+ holdWaitLayout()
1468
+ }
1469
+
958
1470
  waitCallbackRegistered = true
959
1471
 
960
1472
  // Register callback to run animation when unblocked
@@ -964,6 +1476,12 @@
964
1476
  waitUnsubscribe = null
965
1477
  waitCallbackRegistered = false
966
1478
 
1479
+ // Reveal synchronously after the exiting placeholder has
1480
+ // been removed. The parent is fixed-size until the next
1481
+ // frame, so it measures the final entrant instead of an
1482
+ // overlap between exiting and entering content.
1483
+ revealWaitHiddenElement()
1484
+
967
1485
  // Snap to initial state first (in case inline styles were removed)
968
1486
  if (initialKeyframes && element) {
969
1487
  const transformedInitial = transformSVGPathProperties(
@@ -985,9 +1503,12 @@
985
1503
  // which shows up as a "pop" after the deferred animation completes.
986
1504
  pwLog('[motion] wait-unblocked: marking enter handled')
987
1505
  initialAnimationTriggered = true
988
- if (animateProp && typeof animateProp !== 'string') {
1506
+ if (
1507
+ declarativeAnimateProp &&
1508
+ typeof declarativeAnimateProp !== 'string'
1509
+ ) {
989
1510
  objectAnimateRanOnMount = true
990
- lastAnimatePropJson = JSON.stringify(animateProp)
1511
+ lastAnimatePropJson = JSON.stringify(declarativeAnimateProp)
991
1512
  }
992
1513
  isLoaded = 'ready'
993
1514
  })
@@ -995,6 +1516,11 @@
995
1516
  })
996
1517
  return true // Animation was deferred
997
1518
  }
1519
+
1520
+ if (waitHiddenDisplay !== null || waitEnterBlockedBeforeMount) {
1521
+ pwLog('[motion] runAnimation: wait mode no longer blocked, revealing')
1522
+ revealWaitHiddenElement()
1523
+ }
998
1524
  }
999
1525
 
1000
1526
  // Not blocked - run animation immediately
@@ -1018,14 +1544,97 @@
1018
1544
  let objectAnimateRanOnMount = $state(false)
1019
1545
  // Track the serialized animateProp to detect changes for object animate props
1020
1546
  let lastAnimatePropJson = $state<string | undefined>(undefined)
1547
+ let motionDomProjectionUpdatePending = false
1021
1548
  const currentAnimateKey = $derived(
1022
- typeof animateProp === 'string'
1023
- ? animateProp
1549
+ typeof declarativeAnimateProp === 'string'
1550
+ ? declarativeAnimateProp
1024
1551
  : typeof effectiveAnimate === 'string'
1025
1552
  ? effectiveAnimate
1026
1553
  : undefined
1027
1554
  )
1028
1555
 
1556
+ $effect(() => {
1557
+ if (!motionDomProjection) return
1558
+ motionDomProjection.updateOptions({
1559
+ layout: layoutProp,
1560
+ layoutId: scopedLayoutId,
1561
+ layoutScroll: layoutScrollProp,
1562
+ transition: mergedTransition as never,
1563
+ style: styleProp
1564
+ })
1565
+ })
1566
+
1567
+ $effect(() => {
1568
+ if (!motionDomProjection) return
1569
+ if (!element) return
1570
+ motionDomProjection.updateOptions({
1571
+ layout: layoutProp,
1572
+ layoutId: scopedLayoutId,
1573
+ layoutScroll: layoutScrollProp,
1574
+ transition: mergedTransition as never,
1575
+ style: styleProp
1576
+ })
1577
+ motionDomProjection.mount(element)
1578
+ return () => {
1579
+ motionDomProjection.unmount()
1580
+ }
1581
+ })
1582
+
1583
+ let explicitLayoutSnapshot: RectLike | null = null
1584
+ let lastRect: RectLike | null = null
1585
+ const trackLayoutProjectionDependencies = () => [
1586
+ classProp,
1587
+ styleProp,
1588
+ scopedLayoutId,
1589
+ mergedTransition
1590
+ ]
1591
+
1592
+ $effect.pre(() => {
1593
+ const shouldProject = element && layoutProp && isLoaded === 'ready' && hasLayoutFeatures
1594
+ // Track common layout-affecting props so Svelte-owned updates can
1595
+ // snapshot before the DOM patch, matching upstream MeasureLayout.
1596
+ trackLayoutProjectionDependencies()
1597
+
1598
+ if (!shouldProject) {
1599
+ explicitLayoutSnapshot = null
1600
+ return
1601
+ }
1602
+
1603
+ explicitLayoutSnapshot = measureLayoutRect()
1604
+ projection.willUpdate()
1605
+ motionDomProjection?.willUpdate()
1606
+ motionDomProjectionUpdatePending = true
1607
+ })
1608
+
1609
+ $effect(() => {
1610
+ const shouldProject = element && layoutProp && isLoaded === 'ready' && hasLayoutFeatures
1611
+ trackLayoutProjectionDependencies()
1612
+
1613
+ if (!shouldProject || !motionDomProjectionUpdatePending) return
1614
+ motionDomProjectionUpdatePending = false
1615
+ const previous = explicitLayoutSnapshot
1616
+ explicitLayoutSnapshot = null
1617
+ if (previous) {
1618
+ const next = measureLayoutRect()
1619
+ if (next) {
1620
+ if (motionDomProjection) {
1621
+ lastRect = next
1622
+ } else {
1623
+ const flipLayoutMode = layoutProp === 'position' ? 'position' : true
1624
+ const transforms = computeFlipTransforms(previous, next, flipLayoutMode)
1625
+ runFlipAnimation(
1626
+ element!,
1627
+ transforms,
1628
+ (mergedTransition ?? {}) as AnimationOptions
1629
+ )
1630
+ lastRect = next
1631
+ }
1632
+ }
1633
+ }
1634
+ projection.didUpdate()
1635
+ motionDomProjection?.didUpdate()
1636
+ })
1637
+
1029
1638
  // Projection node lifecycle + the `onProjectionUpdate` listener.
1030
1639
  // Mount once the element binds; seed the baseline layout; unmount on
1031
1640
  // cleanup. Depends ONLY on `element` — the `onProjectionUpdate`
@@ -1053,56 +1662,140 @@
1053
1662
  }
1054
1663
  })
1055
1664
 
1056
- // Minimal layout animation using FLIP when `layout` is enabled.
1057
- // When layout === 'position' we only translate.
1058
- // When layout === true we also scale to smoothly interpolate size changes.
1059
- let lastRect: RectLike | null = null
1665
+ // Upstream layout projection via motion-dom. Svelte runes mode doesn't
1666
+ // expose the React-style pre/post render hook pair used upstream, so the
1667
+ // component snapshots committed layout changes through DOM observers while
1668
+ // keeping the existing local ProjectionNode event fan-out alive.
1060
1669
  $effect(() => {
1061
1670
  if (!(element && layoutProp && isLoaded === 'ready' && hasLayoutFeatures)) return
1062
1671
 
1063
- // Initialize last rect on first ready frame. We measure through the
1064
- // projection node rather than `measureRect` directly so the rect is
1065
- // ancestor-transform-invariant: the node resets the whole ancestor
1066
- // chain to its mount-time base while reading, which strips any
1067
- // motion-applied (FLIP/drag) transform up the tree. Without this a
1068
- // child re-measures and FLIPs whenever an ancestor's transform
1069
- // animates, even though the child's own layout never moved. (#379)
1672
+ let rafId: number | null = null
1673
+ let wasViewportOffscreenSinceLastLayout = false
1674
+ let wasViewportScrolledSinceLastLayout = false
1675
+ const flipLayoutMode = layoutProp === 'position' ? 'position' : true
1676
+ motionDomProjection?.seedLayout()
1070
1677
  lastRect = measureLayoutRect()
1071
- // Hint compositor for smoother FLIP transforms
1072
1678
  setCompositorHints(element!, true)
1073
1679
 
1074
- let rafId: number | null = null
1075
- const runFlip = () => {
1076
- // commitLayoutChange does the ancestor-stripped measure, fans
1077
- // the delta out to `onProjectionUpdate` listeners, AND returns
1078
- // the freshly-measured box — so the FLIP reuses that single
1079
- // measurement as `next` instead of walking + transform-resetting
1080
- // the ancestor chain a second time per frame. (#379)
1680
+ const rememberOffscreenScroll = () => {
1681
+ wasViewportScrolledSinceLastLayout = true
1682
+ if (motionDomProjection?.isAnimating()) {
1683
+ motionDomProjection.finishAnimation()
1684
+ }
1685
+ if (isViewportOffscreen(element!.getBoundingClientRect())) {
1686
+ wasViewportOffscreenSinceLastLayout = true
1687
+ motionDomProjection?.finishAnimation()
1688
+ }
1689
+ }
1690
+
1691
+ const commitObservedLayout = () => {
1692
+ if (element!.hasAttribute('data-layout-size-animation')) {
1693
+ return
1694
+ }
1695
+
1696
+ const hasPresenceHold = element!.hasAttribute(presenceLayoutHoldAttribute)
1697
+ const hasHiddenWaitEnter = !!element!.querySelector(
1698
+ '[data-presence-wait-hidden="true"]'
1699
+ )
1700
+ const hasPresencePlaceholder = !!element!.querySelector(
1701
+ '[data-presence-placeholder="true"]'
1702
+ )
1703
+
1704
+ if (hasPresenceHold || hasHiddenWaitEnter) {
1705
+ return
1706
+ }
1707
+
1708
+ if (hasPresencePlaceholder) {
1709
+ lastRect = measureLayoutRect()
1710
+ motionDomProjection?.seedLayout()
1711
+ return
1712
+ }
1713
+
1714
+ if (
1715
+ wasViewportScrolledSinceLastLayout ||
1716
+ wasViewportOffscreenSinceLastLayout ||
1717
+ isViewportOffscreen(element!.getBoundingClientRect())
1718
+ ) {
1719
+ lastRect = measureLayoutRect()
1720
+ motionDomProjection?.finishAnimation()
1721
+ wasViewportScrolledSinceLastLayout = false
1722
+ wasViewportOffscreenSinceLastLayout = false
1723
+ return
1724
+ }
1725
+
1081
1726
  const nextBox = projection.commitLayoutChange()
1082
- if (!nextBox) return
1083
- const next = boxToRectLike(nextBox)
1084
- if (!lastRect) {
1727
+ let shouldCommitMotionDomLayout = false
1728
+ if (nextBox && lastRect) {
1729
+ const next = boxToRectLike(nextBox)
1730
+ shouldCommitMotionDomLayout = hasRectChanged(lastRect, next)
1731
+ if (!motionDomProjection) {
1732
+ const transforms = computeFlipTransforms(lastRect, next, flipLayoutMode)
1733
+ runFlipAnimation(
1734
+ element!,
1735
+ transforms,
1736
+ (mergedTransition ?? {}) as AnimationOptions
1737
+ )
1738
+ }
1085
1739
  lastRect = next
1086
- return
1740
+ wasViewportScrolledSinceLastLayout = false
1741
+ wasViewportOffscreenSinceLastLayout = false
1742
+ } else if (nextBox) {
1743
+ lastRect = boxToRectLike(nextBox)
1744
+ wasViewportScrolledSinceLastLayout = false
1745
+ wasViewportOffscreenSinceLastLayout = false
1746
+ }
1747
+ if (shouldCommitMotionDomLayout) {
1748
+ motionDomProjection?.commitObservedLayoutChange()
1087
1749
  }
1088
- const transforms = computeFlipTransforms(lastRect, next, layoutProp ?? false)
1089
- runFlipAnimation(element!, transforms, (mergedTransition ?? {}) as AnimationOptions)
1750
+ }
1751
+
1752
+ const commitPresenceLayoutRelease = (event: Event) => {
1753
+ const detail = (
1754
+ event as CustomEvent<{
1755
+ previousRect?: RectLike
1756
+ viewportScrolledDuringHold?: boolean
1757
+ }>
1758
+ ).detail
1759
+ const previous = detail?.previousRect
1760
+ const viewportRect = element!.getBoundingClientRect()
1761
+ const next = measureLayoutRect()
1762
+ if (!(previous && next)) return
1763
+
1090
1764
  lastRect = next
1765
+ const shouldSkipLayoutAnimation =
1766
+ detail?.viewportScrolledDuringHold ||
1767
+ wasViewportScrolledSinceLastLayout ||
1768
+ wasViewportOffscreenSinceLastLayout ||
1769
+ isViewportOffscreen(viewportRect)
1770
+ if (!shouldSkipLayoutAnimation && !motionDomProjection) {
1771
+ const transforms = computeFlipTransforms(previous, next, flipLayoutMode)
1772
+ runFlipAnimation(element!, transforms, (mergedTransition ?? {}) as AnimationOptions)
1773
+ }
1774
+ wasViewportScrolledSinceLastLayout = false
1775
+ wasViewportOffscreenSinceLastLayout = false
1776
+ if (!shouldSkipLayoutAnimation && hasRectChanged(previous, next)) {
1777
+ motionDomProjection?.commitObservedLayoutChange()
1778
+ } else if (shouldSkipLayoutAnimation) {
1779
+ motionDomProjection?.finishAnimation()
1780
+ }
1091
1781
  }
1092
1782
 
1093
- const scheduleFlip = () => {
1094
- if (rafId) cancelAnimationFrame(rafId)
1783
+ const scheduleProjectionCommit = () => {
1784
+ if (rafId) return
1785
+ commitObservedLayout()
1095
1786
  rafId = requestAnimationFrame(() => {
1096
1787
  rafId = null
1097
- runFlip()
1098
1788
  })
1099
1789
  }
1100
- const disconnectObservers = observeLayoutChanges(element!, () => scheduleFlip())
1790
+ const disconnectObservers = observeLayoutChanges(element!, () => scheduleProjectionCommit())
1791
+ window.addEventListener('scroll', rememberOffscreenScroll, { passive: true })
1792
+ element!.addEventListener(presenceLayoutReleaseEvent, commitPresenceLayoutRelease)
1101
1793
 
1102
1794
  return () => {
1103
1795
  disconnectObservers()
1796
+ window.removeEventListener('scroll', rememberOffscreenScroll)
1797
+ element?.removeEventListener(presenceLayoutReleaseEvent, commitPresenceLayoutRelease)
1104
1798
  lastRect = null
1105
- // Reset compositor hints on teardown
1106
1799
  if (element) {
1107
1800
  setCompositorHints(element, false)
1108
1801
  }
@@ -1126,6 +1819,7 @@
1126
1819
 
1127
1820
  const prev = layoutIdRegistry.consume(scopedLayoutId)
1128
1821
  if (!prev) return // First appearance, no animation needed
1822
+ if (motionDomProjection) return
1129
1823
 
1130
1824
  const next = measureRect(element, resolveLayoutScrollAncestors())
1131
1825
  const transforms = computeFlipTransforms(prev.rect, next, true)
@@ -1235,6 +1929,22 @@
1235
1929
  )
1236
1930
  })
1237
1931
 
1932
+ // Legacy animation controls (`animate={controls}`) mirror upstream's
1933
+ // VisualElement subscription model with a small Svelte adapter. The
1934
+ // controls own when animations start; this component only resolves
1935
+ // variants/custom data and runs the resulting target on its element.
1936
+ $effect(() => {
1937
+ if (!(element && animateControls)) return
1938
+
1939
+ const subscriber: AnimationControlsSubscriber = {
1940
+ start: startAnimationControlsDefinition,
1941
+ set: applyAnimationControlsTarget,
1942
+ stop: stopAnimationControlsAnimations
1943
+ }
1944
+
1945
+ return animateControls.subscribe(subscriber)
1946
+ })
1947
+
1238
1948
  // Handle key prop changes inside AnimatePresence (simulates React's key-based remounting)
1239
1949
  // When key changes, run exit → initial → animate sequence on the same element
1240
1950
  $effect(() => {
@@ -1343,10 +2053,14 @@
1343
2053
  // Re-run animate when animateProp changes while ready
1344
2054
  $effect(() => {
1345
2055
  if (!(element && isLoaded === 'ready')) return
2056
+ if (animateControls) return
1346
2057
  // Skip first run if we mounted with initial={false} AND the variant hasn't changed
1347
2058
  if (mountedWithInitialFalse) {
1348
2059
  // Only skip if the variant is the same as what we mounted with
1349
- if (typeof animateProp === 'string' && lastRanVariantKey === animateProp) {
2060
+ if (
2061
+ typeof declarativeAnimateProp === 'string' &&
2062
+ lastRanVariantKey === declarativeAnimateProp
2063
+ ) {
1350
2064
  mountedWithInitialFalse = false
1351
2065
  return
1352
2066
  }
@@ -1358,25 +2072,28 @@
1358
2072
  pwLog('[motion] effect: skipping, initial animation already triggered')
1359
2073
  initialAnimationTriggered = false
1360
2074
  // Also mark object animate as ran to prevent duplicate runs from effect re-triggers
1361
- if (animateProp && typeof animateProp !== 'string') {
2075
+ if (declarativeAnimateProp && typeof declarativeAnimateProp !== 'string') {
1362
2076
  objectAnimateRanOnMount = true
1363
2077
  }
1364
2078
  return
1365
2079
  }
1366
- if (typeof animateProp === 'string') {
2080
+ if (typeof declarativeAnimateProp === 'string') {
1367
2081
  // Compare BOTH the variant key and the resolved keyframes JSON.
1368
2082
  // For static variants the JSON is constant per key; for
1369
2083
  // function-form variants the JSON changes when `custom`
1370
2084
  // changes, which we must treat as a new animation target.
1371
2085
  const resolvedJson = resolvedAnimate ? JSON.stringify(resolvedAnimate) : undefined
1372
- if (lastRanVariantKey !== animateProp || lastRanResolvedJson !== resolvedJson) {
1373
- lastRanVariantKey = animateProp
2086
+ if (
2087
+ lastRanVariantKey !== declarativeAnimateProp ||
2088
+ lastRanResolvedJson !== resolvedJson
2089
+ ) {
2090
+ lastRanVariantKey = declarativeAnimateProp
1374
2091
  lastRanResolvedJson = resolvedJson
1375
2092
  runAnimation()
1376
2093
  }
1377
- } else if (animateProp) {
2094
+ } else if (declarativeAnimateProp) {
1378
2095
  // Object animate props - detect if the prop actually changed
1379
- const currentJson = JSON.stringify(animateProp)
2096
+ const currentJson = JSON.stringify(declarativeAnimateProp)
1380
2097
  const propChanged = lastAnimatePropJson !== currentJson
1381
2098
 
1382
2099
  // Reset flag if animate prop changed
@@ -1398,7 +2115,7 @@
1398
2115
  // Also run when inherited/effective variant changes
1399
2116
  $effect(() => {
1400
2117
  void resolvedAnimate
1401
- if (!(element && isLoaded === 'ready' && !animateProp && resolvedAnimate)) return
2118
+ if (!(element && isLoaded === 'ready' && !declarativeAnimateProp && resolvedAnimate)) return
1402
2119
  // Skip first run if we mounted with initial={false} AND the variant hasn't changed
1403
2120
  if (mountedWithInitialFalse) {
1404
2121
  // Only skip if the variant is the same as what we mounted with
@@ -1423,6 +2140,7 @@
1423
2140
 
1424
2141
  $effect(() => {
1425
2142
  if (!(element && isLoaded === 'mounting')) return
2143
+ markMotionMounted()
1426
2144
 
1427
2145
  pwLog('[motion] main effect running', {
1428
2146
  effectiveAnimate: !!effectiveAnimate,
@@ -1452,6 +2170,30 @@
1452
2170
  dataPath = 5
1453
2171
  isLoaded = 'ready'
1454
2172
  } else if (isNotEmpty(initialKeyframes)) {
2173
+ const canHandoffOptimizedAppear = hasOptimizedAppearAnimation(optimizedAppearId)
2174
+ if (canHandoffOptimizedAppear) {
2175
+ pwLog('[motion] path: optimized appear handoff')
2176
+ dataPath = 6
2177
+ isLoaded = 'initial'
2178
+ initialAnimationTriggered = true
2179
+ if (declarativeAnimateProp && typeof declarativeAnimateProp !== 'string') {
2180
+ objectAnimateRanOnMount = true
2181
+ lastAnimatePropJson = JSON.stringify(declarativeAnimateProp)
2182
+ }
2183
+ finishOptimizedAppearAnimation(optimizedAppearId)
2184
+ .then(() => {
2185
+ applyAnimateRestingStyle()
2186
+ enterAnimationSettled = true
2187
+ isLoaded = 'ready'
2188
+ onAnimationCompleteProp?.(
2189
+ resolvedAnimate as DOMKeyframesDefinition | undefined
2190
+ )
2191
+ })
2192
+ .catch(() => {
2193
+ isLoaded = 'ready'
2194
+ })
2195
+ return
2196
+ }
1455
2197
  pwLog('[motion] path: has initialKeyframes, will animate to target')
1456
2198
  // Apply initial instantly BEFORE exposing 'initial' state
1457
2199
  const transformedInitial = transformSVGPathProperties(
@@ -1500,8 +2242,8 @@
1500
2242
 
1501
2243
  // Mark that we're triggering the initial animation to prevent duplicate runs
1502
2244
  initialAnimationTriggered = true
1503
- if (animateProp && typeof animateProp !== 'string') {
1504
- lastAnimatePropJson = JSON.stringify(animateProp)
2245
+ if (declarativeAnimateProp && typeof declarativeAnimateProp !== 'string') {
2246
+ lastAnimatePropJson = JSON.stringify(declarativeAnimateProp)
1505
2247
  }
1506
2248
 
1507
2249
  // IMPORTANT: Start the animation BEFORE changing isLoaded.
@@ -1572,15 +2314,23 @@
1572
2314
  {#if isVoidTag}
1573
2315
  {#if isSVGTag(String(tag))}
1574
2316
  <svelte:element this={tag} bind:this={element} xmlns={SVG_NAMESPACE} {...derivedAttrs} />
2317
+ <!-- trunk-ignore(eslint/svelte/no-at-html-tags): optimized appear emits a JSON-escaped SSR bootstrap script, not user-authored HTML. -->
2318
+ {@html renderedOptimizedAppearScript}
1575
2319
  {:else}
1576
2320
  <svelte:element this={tag} bind:this={element} {...derivedAttrs} />
2321
+ <!-- trunk-ignore(eslint/svelte/no-at-html-tags): optimized appear emits a JSON-escaped SSR bootstrap script, not user-authored HTML. -->
2322
+ {@html renderedOptimizedAppearScript}
1577
2323
  {/if}
1578
2324
  {:else if isSVGTag(String(tag))}
1579
2325
  <svelte:element this={tag} bind:this={element} xmlns={SVG_NAMESPACE} {...derivedAttrs}>
1580
2326
  {@render children?.()}
1581
2327
  </svelte:element>
2328
+ <!-- trunk-ignore(eslint/svelte/no-at-html-tags): optimized appear emits a JSON-escaped SSR bootstrap script, not user-authored HTML. -->
2329
+ {@html renderedOptimizedAppearScript}
1582
2330
  {:else}
1583
2331
  <svelte:element this={tag} bind:this={element} {...derivedAttrs}>
1584
2332
  {@render children?.()}
1585
2333
  </svelte:element>
2334
+ <!-- trunk-ignore(eslint/svelte/no-at-html-tags): optimized appear emits a JSON-escaped SSR bootstrap script, not user-authored HTML. -->
2335
+ {@html renderedOptimizedAppearScript}
1586
2336
  {/if}