@humanspeak/svelte-motion 0.5.1 → 0.5.3

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.
@@ -0,0 +1,22 @@
1
+ import type { ProjectionNode } from '../utils/projection';
2
+ /**
3
+ * Publish a `ProjectionNode` as the projection parent for descendant
4
+ * motion components.
5
+ *
6
+ * @param node The current component's ProjectionNode, or `null` to
7
+ * explicitly clear (rarely needed — Svelte context auto-clears on
8
+ * component unmount).
9
+ */
10
+ export declare const setProjectionParent: (node: ProjectionNode | null) => void;
11
+ /**
12
+ * Read the ancestor `ProjectionNode` published by the nearest motion
13
+ * ancestor. Returns `null` when this component is at the root of the
14
+ * projection tree (or when no motion ancestor exists).
15
+ *
16
+ * Must be called BEFORE the current component publishes its own node
17
+ * via `setProjectionParent` — see the order-of-operations note in this
18
+ * file's header.
19
+ *
20
+ * @returns The parent ProjectionNode, or `null`.
21
+ */
22
+ export declare const getProjectionParent: () => ProjectionNode | null;
@@ -0,0 +1,56 @@
1
+ import { getContext, setContext } from 'svelte';
2
+ /**
3
+ * Svelte context plumbing for the projection tree.
4
+ *
5
+ * Every `motion.*` element creates a `ProjectionNode` at setup time and
6
+ * publishes it via `setProjectionParent(node)` so descendant motion
7
+ * elements can pick it up via `getProjectionParent()` and wire themselves
8
+ * as `node.parent` + register in `node.parent.children`. The tree shape
9
+ * mirrors the Svelte component tree exactly because Svelte's
10
+ * `setContext` propagates down through descendants in component-init
11
+ * order.
12
+ *
13
+ * This is the closest analog we have to framer-motion's depth-sorted
14
+ * FlatTree (`projection/node/create-projection-node.ts`), but without
15
+ * the global `root` registry — parent/child pointers are sufficient
16
+ * for the workflows this PR enables (drag↔swap origin compensation,
17
+ * ancestor-zeroing measure). A global root would only be needed once we
18
+ * port shared-element `layoutId` morphing onto projection nodes; the
19
+ * current `layoutId.ts` registry continues to handle that case
20
+ * independently.
21
+ *
22
+ * Order of operations inside a single `motion.*` component (CRITICAL):
23
+ * 1. Read parent via `getProjectionParent()` BEFORE creating own node.
24
+ * 2. Construct own node, set `node.parent = parent`.
25
+ * 3. Publish own node via `setProjectionParent(node)`.
26
+ * If steps 1 and 3 are reversed, the component sees ITS OWN node as
27
+ * the parent (because `setContext` shadows from the call site down)
28
+ * and the tree collapses to a chain of self-references. Same trap
29
+ * `layoutScroll.context.ts` documents — don't repeat it.
30
+ */
31
+ const PROJECTION_CONTEXT_KEY = Symbol('svelte-motion:projection-parent');
32
+ /**
33
+ * Publish a `ProjectionNode` as the projection parent for descendant
34
+ * motion components.
35
+ *
36
+ * @param node The current component's ProjectionNode, or `null` to
37
+ * explicitly clear (rarely needed — Svelte context auto-clears on
38
+ * component unmount).
39
+ */
40
+ export const setProjectionParent = (node) => {
41
+ setContext(PROJECTION_CONTEXT_KEY, node);
42
+ };
43
+ /**
44
+ * Read the ancestor `ProjectionNode` published by the nearest motion
45
+ * ancestor. Returns `null` when this component is at the root of the
46
+ * projection tree (or when no motion ancestor exists).
47
+ *
48
+ * Must be called BEFORE the current component publishes its own node
49
+ * via `setProjectionParent` — see the order-of-operations note in this
50
+ * file's header.
51
+ *
52
+ * @returns The parent ProjectionNode, or `null`.
53
+ */
54
+ export const getProjectionParent = () => {
55
+ return getContext(PROJECTION_CONTEXT_KEY) ?? null;
56
+ };
@@ -17,17 +17,21 @@
17
17
  DragControls,
18
18
  DragTransition,
19
19
  MotionWhileDrag,
20
- DragInfo
20
+ DragInfo,
21
+ MotionOnPan,
22
+ MotionOnPanEnd,
23
+ MotionOnPanSessionStart,
24
+ MotionOnPanStart
21
25
  } from '../types'
22
26
  import { isNotEmpty } from '../utils/objects'
23
27
  import { sleep } from '../utils/testing'
24
28
  import { animate, type AnimationOptions, type DOMKeyframesDefinition } from 'motion'
25
29
  import { isPlaywrightEnv, pwLog } from '../utils/log'
26
- import { onDestroy, type Snippet } from 'svelte'
30
+ import { onDestroy, untrack, type Snippet } from 'svelte'
27
31
  import { VOID_TAGS } from '../utils/constants'
28
32
  import { mergeTransitions, animateWithLifecycle } from '../utils/animation'
29
33
  import { attachWhileTap } from '../utils/interaction'
30
- import { attachWhileHover } from '../utils/hover'
34
+ import { attachWhileHover, computeHoverBaseline, splitHoverDefinition } from '../utils/hover'
31
35
  import { attachWhileFocus } from '../utils/focus'
32
36
  import { attachWhileInView } from '../utils/inView.svelte'
33
37
  import {
@@ -35,10 +39,11 @@
35
39
  computeFlipTransforms,
36
40
  runFlipAnimation,
37
41
  setCompositorHints,
38
- observeLayoutChanges
42
+ observeLayoutChanges,
43
+ type RectLike
39
44
  } from '../utils/layout'
40
45
  import type { SvelteHTMLElements } from 'svelte/elements'
41
- import { mergeInlineStyles } from '../utils/style'
46
+ import { mergeInlineStyles, extractTransform } from '../utils/style'
42
47
  import { isNativelyFocusable } from '../utils/a11y'
43
48
  import {
44
49
  getAnimatePresenceContext,
@@ -48,7 +53,16 @@
48
53
  } from '../utils/presence'
49
54
  import { getInitialKeyframes } from '../utils/initial'
50
55
  import { attachDrag } from '../utils/drag'
51
- import { resolveInitial, resolveAnimate, resolveExit, resolveWhile } from '../utils/variants'
56
+ import { attachPan, type AttachPanCleanup } from '../utils/pan'
57
+ import { ProjectionNode } from '../utils/projection'
58
+ import { getProjectionParent, setProjectionParent } from '../components/projection.context'
59
+ import {
60
+ resolveInitial,
61
+ resolveAnimate,
62
+ resolveExit,
63
+ resolveWhile,
64
+ resolveRestingValues
65
+ } from '../utils/variants'
52
66
  import {
53
67
  setVariantContext,
54
68
  getVariantContext,
@@ -97,6 +111,11 @@
97
111
  whileInView: whileInViewProp,
98
112
  viewport: viewportProp,
99
113
  whileDrag: whileDragProp,
114
+ whilePan: whilePanProp,
115
+ onPanSessionStart: onPanSessionStartProp,
116
+ onPanStart: onPanStartProp,
117
+ onPan: onPanProp,
118
+ onPanEnd: onPanEndProp,
100
119
  onHoverStart: onHoverStartProp,
101
120
  onHoverEnd: onHoverEndProp,
102
121
  onFocusStart: onFocusStartProp,
@@ -124,10 +143,17 @@
124
143
  layout: layoutProp,
125
144
  layoutId: layoutIdProp,
126
145
  layoutScroll: layoutScrollProp,
146
+ onProjectionUpdate: onProjectionUpdateProp,
127
147
  ref: element = $bindable(null),
128
148
  ...rest
129
149
  }: Props = $props()
130
150
  let isLoaded = $state<'mounting' | 'initial' | 'ready' | 'animated'>('mounting')
151
+ // True once the enter/animate animation has COMPLETED. Until then the
152
+ // WAAPI animation owns the transform; flipping the inline baseline to
153
+ // the target mid-run causes a one-frame snap (the target shows through
154
+ // for the frame the inline changes). We therefore only apply the target
155
+ // as the inline style once settled — see the style derivation. (#377)
156
+ let enterAnimationSettled = $state(false)
131
157
  let dataPath = $state<number>(-1)
132
158
  const motionConfig = $derived(getMotionConfig())
133
159
  const reducedMotionState = useReducedMotionConfig()
@@ -181,6 +207,46 @@
181
207
  return refs.filter((el): el is HTMLElement => Boolean(el))
182
208
  }
183
209
 
210
+ // Projection tree wiring (#379). Capture the parent node BEFORE
211
+ // publishing our own — same shadowing trap as layoutScroll above.
212
+ // The node measures through `resolveLayoutScrollAncestors` so its
213
+ // boxes share the FLIP coordinate space, and zeros ancestor
214
+ // transforms during measure so nested layout-animated parents don't
215
+ // corrupt a child's delta.
216
+ // The user-authored transform, sourced from the `style` prop rather
217
+ // than the live inline transform — the latter already carries any
218
+ // transform-type `initial`/`animate` keyframe by the time the node
219
+ // measures, which would be mistaken for the user's base.
220
+ const userBaseTransform = $derived(extractTransform(styleProp))
221
+
222
+ const projectionParent = getProjectionParent()
223
+ const projection = new ProjectionNode({
224
+ parent: projectionParent,
225
+ getScrollContainers: resolveLayoutScrollAncestors,
226
+ getBaseTransform: () => userBaseTransform
227
+ })
228
+ setProjectionParent(projection)
229
+
230
+ // Convert a projection `Box` (ancestor chain reset to base, self
231
+ // transform stripped, scroll containers compensated) to the
232
+ // `RectLike` shape `computeFlipTransforms` consumes.
233
+ const boxToRectLike = (box: {
234
+ x: { min: number; max: number }
235
+ y: { min: number; max: number }
236
+ }): RectLike => ({
237
+ left: box.x.min,
238
+ top: box.y.min,
239
+ width: box.x.max - box.x.min,
240
+ height: box.y.max - box.y.min
241
+ })
242
+
243
+ // Ancestor-transform-invariant layout measurement for seeding the
244
+ // FLIP effect's first rect.
245
+ const measureLayoutRect = (): RectLike | null => {
246
+ const box = projection.measure()
247
+ return box ? boxToRectLike(box) : null
248
+ }
249
+
184
250
  // Get current presence depth (0 = direct child of AnimatePresence, undefined = not in AnimatePresence)
185
251
  const presenceDepth = getPresenceDepth()
186
252
 
@@ -455,6 +521,7 @@
455
521
  const resolvedWhileHover = $derived(resolveWhile(whileHoverProp, variantsProp, effectiveCustom))
456
522
  const resolvedWhileFocus = $derived(resolveWhile(whileFocusProp, variantsProp, effectiveCustom))
457
523
  const resolvedWhileDrag = $derived(resolveWhile(whileDragProp, variantsProp, effectiveCustom))
524
+ const resolvedWhilePan = $derived(resolveWhile(whilePanProp, variantsProp, effectiveCustom))
458
525
  const resolvedWhileInView = $derived(
459
526
  resolveWhile(whileInViewProp, variantsProp, effectiveCustom)
460
527
  )
@@ -467,6 +534,17 @@
467
534
  )
468
535
  )
469
536
 
537
+ // Reduced-motion-filtered animate values used as the inline-style
538
+ // baseline so an animated value (transforms included) persists after
539
+ // the WAAPI animation completes (#377). Filtered exactly like
540
+ // `initialKeyframes` so transforms are stripped under reduced motion.
541
+ const animateKeyframes = $derived(
542
+ filterReducedMotionKeyframes(
543
+ resolvedAnimate as Record<string, unknown> | undefined,
544
+ reducedMotion
545
+ )
546
+ )
547
+
470
548
  // Derived attributes to keep both branches in sync (focusability, data flags, style, class)
471
549
  const derivedAttrs = $derived<Record<string, unknown>>({
472
550
  ...(rest as Record<string, unknown>),
@@ -505,20 +583,33 @@
505
583
  initialKeyframes && 'pathLength' in initialKeyframes && isLoaded === 'mounting'
506
584
  ? `${styleProp || ''};visibility:hidden`
507
585
  : styleProp,
508
- // Apply initialKeyframes as inline styles during mounting and initial phases
509
- // The animation starts in RAF after 'initial' phase, so we need styles until then
510
- // When ready AND we have initialKeyframes: DON'T set any animated properties!
511
- // WAAPI is controlling them and inline styles can override the animation
586
+ // The "from" slot: apply initialKeyframes as inline styles during
587
+ // the mounting/initial phases (before the WAAPI animation locks
588
+ // its from-value and we promote to 'ready' see the lifecycle
589
+ // around the enter rAF). mergeInlineStyles prefers this slot when
590
+ // non-empty, so it wins over the animate slot below in these phases.
512
591
  isLoaded === 'mounting' || isLoaded === 'initial'
513
592
  ? (initialKeyframes as unknown as Record<string, unknown>)
514
593
  : undefined,
515
- // Only use resolvedAnimate as fallback when we DON'T have initialKeyframes
516
- // If we have initialKeyframes, the enter animation is running - setting
517
- // inline styles to the target values will override the WAAPI animation
518
- // Use isNotEmpty to handle empty initial objects (initial: {}) which should fallback
519
- isNotEmpty(initialKeyframes)
520
- ? undefined
521
- : (resolvedAnimate as unknown as Record<string, unknown>)
594
+ // The "target" slot. Only AFTER the enter animation completes does
595
+ // the target become the inline baseline, so the element holds it
596
+ // once WAAPI surrenders the property (default fill:'none' would
597
+ // otherwise leave transform:none). It must NOT be applied during
598
+ // the run: flipping the inline value to the target mid-animation
599
+ // shows the target for the one frame the inline changes (a visible
600
+ // snap), since WAAPI's composite doesn't override that exact frame.
601
+ // While the animation runs we keep the original behavior — initial
602
+ // keyframes own the inline (via the slot above), or, with no
603
+ // initial, the animate values seed the inline as the from. Resting
604
+ // values collapse keyframe arrays to their last element
605
+ // (animate={{x:[0,100,50]}} rests at 50). (#377)
606
+ enterAnimationSettled
607
+ ? (resolveRestingValues(
608
+ animateKeyframes as DOMKeyframesDefinition | undefined
609
+ ) as unknown as Record<string, unknown>)
610
+ : isNotEmpty(initialKeyframes)
611
+ ? undefined
612
+ : (animateKeyframes as unknown as Record<string, unknown>)
522
613
  ),
523
614
  class: classProp
524
615
  })
@@ -594,6 +685,172 @@
594
685
  }
595
686
  })
596
687
 
688
+ /**
689
+ * Pan-gesture wiring. Active whenever any of `onPanSessionStart`,
690
+ * `onPanStart`, `onPan`, `onPanEnd`, or `whilePan` is set. Unlike
691
+ * `drag`, Pan has no constraints / momentum / origin-snap — it's a
692
+ * pure pointer offset+velocity reporter, useful for swipe-to-dismiss
693
+ * sheets, custom carousels, and any "tell me what the gesture is
694
+ * doing right now" interaction. Mirrors framer-motion's `PanGesture`
695
+ * (packages/framer-motion/src/gestures/pan/index.ts).
696
+ *
697
+ * Split into TWO effects:
698
+ *
699
+ * 1. `attach` — keyed on `element`, `isLoaded === 'ready'`, presence
700
+ * of any pan handler/whilePan, and absence of `drag` (drag takes
701
+ * precedence — upstream framer-motion routes drag THROUGH the pan
702
+ * gesture internally, so co-attaching pan when drag is on would
703
+ * fight transforms). Creates / tears down the underlying
704
+ * `attachPan` lifetime once per element-bound interval.
705
+ *
706
+ * 2. `swap` — keyed on the user's handler/whilePan props. Calls
707
+ * `teardownPan.update(next)` to hot-swap the live handler set
708
+ * without destroying the in-flight `PanSession`. Without this
709
+ * split, every parent re-render that produces a fresh inline
710
+ * arrow handler would tear down the live gesture mid-pan —
711
+ * pointer listeners removed, no `onPanEnd` ever fires, whilePan
712
+ * keyframes leak.
713
+ */
714
+ let teardownPan: AttachPanCleanup | null = null
715
+ let activeWhilePanKeyframes: Record<string, unknown> | null = null
716
+ let whilePanBaseline: Record<string, unknown> | null = null
717
+
718
+ /**
719
+ * Boolean presence-check for "is any pan surface active?". Derived
720
+ * so the attach effect below tracks the *boolean value*, not the
721
+ * individual handler/whilePan reference identities. A consumer
722
+ * passing `onPan={(e, i) => ...}` (inline arrow — fresh ref every
723
+ * render) used to re-trigger the attach effect on every parent
724
+ * render; with this derived in place, the attach effect only
725
+ * re-runs when overall presence flips (none → some, some → none).
726
+ * Per-ref changes flow through the hot-swap effect instead.
727
+ */
728
+ const hasAnyPanHandler = $derived(
729
+ !!onPanProp ||
730
+ !!onPanStartProp ||
731
+ !!onPanEndProp ||
732
+ !!onPanSessionStartProp ||
733
+ !!resolvedWhilePan
734
+ )
735
+
736
+ const buildPanHandlers = (): {
737
+ onSessionStart?: MotionOnPanSessionStart
738
+ onStart: NonNullable<MotionOnPanStart>
739
+ onMove?: MotionOnPan
740
+ onEnd: NonNullable<MotionOnPanEnd>
741
+ } => ({
742
+ onSessionStart: onPanSessionStartProp,
743
+ onStart: (event, info) => {
744
+ if (resolvedWhilePan && element) {
745
+ // Snapshot the values we'll revert to BEFORE applying — same
746
+ // `computeHoverBaseline` path the other while-* gestures
747
+ // (whileHover/whileFocus/drag) use. Covers animatable transform
748
+ // shorthands (scale, rotate, x, y) AND restores non-animatable
749
+ // inline writes (cursor, pointer-events) since the baseline
750
+ // sniffs `animate` → `initial` → computed style → inline style.
751
+ whilePanBaseline = computeHoverBaseline(element, {
752
+ initial: (initialKeyframes ?? {}) as Record<string, unknown>,
753
+ animate: (resolvedAnimate ?? {}) as Record<string, unknown>,
754
+ whileHover: (resolvedWhilePan ?? {}) as Record<string, unknown>
755
+ })
756
+ const { keyframes, transition } = splitHoverDefinition(
757
+ resolvedWhilePan as Record<string, unknown>
758
+ )
759
+ activeWhilePanKeyframes = keyframes
760
+ animateWithLifecycle(
761
+ element,
762
+ keyframes as unknown as DOMKeyframesDefinition,
763
+ (transition ?? mergedTransition ?? {}) as unknown as AnimationOptions
764
+ )
765
+ }
766
+ onPanStartProp?.(event, info)
767
+ },
768
+ onMove: onPanProp,
769
+ onEnd: (event, info) => {
770
+ if (activeWhilePanKeyframes && whilePanBaseline && element) {
771
+ animateWithLifecycle(
772
+ element,
773
+ whilePanBaseline as unknown as DOMKeyframesDefinition,
774
+ (mergedTransition ?? {}) as unknown as AnimationOptions
775
+ )
776
+ }
777
+ activeWhilePanKeyframes = null
778
+ whilePanBaseline = null
779
+ onPanEndProp?.(event, info)
780
+ }
781
+ })
782
+
783
+ $effect(() => {
784
+ if (isPlaywright) {
785
+ pwLog('[motion] pan attach effect run', {
786
+ hasAnyPanHandler,
787
+ isLoaded
788
+ })
789
+ }
790
+ if (!element) return
791
+ // Defer attachment until the element has settled out of the enter
792
+ // animation phase — matches the gate every other gesture effect
793
+ // in this file uses (drag, whileTap, whileHover, whileFocus,
794
+ // whileInView). Without this, a pointerdown during the
795
+ // initial / mounting phase would attach pan listeners against an
796
+ // element whose enter animation hasn't committed its baseline.
797
+ if (isLoaded !== 'ready') return
798
+ // Drag takes precedence — upstream framer-motion's drag gesture is
799
+ // implemented ON TOP of Pan, not alongside it. Co-attaching here
800
+ // would create two competing pointer pipelines fighting for the
801
+ // same transforms.
802
+ if (dragProp) return
803
+ if (!hasAnyPanHandler) return
804
+
805
+ // `untrack` so the reactive reads inside `buildPanHandlers`
806
+ // (onPan*Prop, resolvedWhilePan, initialKeyframes, resolvedAnimate,
807
+ // mergedTransition) don't register as dependencies of this attach
808
+ // effect. Otherwise every parent re-render that passes a fresh
809
+ // inline arrow handler would re-run this effect and call
810
+ // `teardownPan?.()`, killing the live PanSession mid-gesture.
811
+ // Handler-ref changes flow exclusively through the hot-swap
812
+ // effect below, which calls `teardownPan.update(next)` — that's
813
+ // the path that keeps an in-flight gesture alive across re-renders.
814
+ teardownPan = attachPan(
815
+ element,
816
+ untrack(() => buildPanHandlers())
817
+ )
818
+
819
+ return () => {
820
+ // Synchronous revert of whilePan + lifecycle dispatch lives in
821
+ // attachPan.teardown() — the cleanup chain there calls
822
+ // session.dispatchTerminal(rawHandlers) BEFORE flipping isAlive,
823
+ // so onPanEnd fires (which runs the revert above) before the
824
+ // listeners go. dispatchTerminal is idempotent (PanSession's
825
+ // terminalDispatched flag) so a host that tears down after a
826
+ // natural release won't replay the lifecycle pair.
827
+ teardownPan?.()
828
+ teardownPan = null
829
+ activeWhilePanKeyframes = null
830
+ whilePanBaseline = null
831
+ }
832
+ })
833
+
834
+ /**
835
+ * Hot-swap effect — propagates handler / whilePan changes onto the
836
+ * existing PanSession via `teardownPan.update(next)`. Tracked
837
+ * separately from the attach effect so a fresh inline-arrow handler
838
+ * reference does NOT trigger teardown + re-attach. Without this
839
+ * split, every parent re-render mid-gesture would silently kill the
840
+ * live pan session.
841
+ */
842
+ $effect(() => {
843
+ // Track every prop the handler set depends on so this effect
844
+ // re-runs when any of them change.
845
+ void onPanSessionStartProp
846
+ void onPanStartProp
847
+ void onPanProp
848
+ void onPanEndProp
849
+ void resolvedWhilePan
850
+ if (!teardownPan) return
851
+ teardownPan.update(buildPanHandlers())
852
+ })
853
+
597
854
  /**
598
855
  * Execute the actual animation without wait mode checks.
599
856
  */
@@ -630,12 +887,20 @@
630
887
  transitionAnimate
631
888
  })
632
889
 
890
+ // A fresh run owns the transform again until it completes.
891
+ enterAnimationSettled = false
633
892
  animateWithLifecycle(
634
893
  element,
635
894
  payload as unknown as DOMKeyframesDefinition,
636
895
  transitionAnimate as unknown as AnimationOptions,
637
896
  (def) => onAnimationStartProp?.(def as unknown as DOMKeyframesDefinition | undefined),
638
- (def) => onAnimationCompleteProp?.(def as unknown as DOMKeyframesDefinition | undefined)
897
+ (def) => {
898
+ // Now the target is the resting state — promote it to the
899
+ // inline baseline so it persists after WAAPI surrenders the
900
+ // property (default fill:'none'). (#377)
901
+ enterAnimationSettled = true
902
+ onAnimationCompleteProp?.(def as unknown as DOMKeyframesDefinition | undefined)
903
+ }
639
904
  )
640
905
  }
641
906
 
@@ -753,26 +1018,65 @@
753
1018
  : undefined
754
1019
  )
755
1020
 
1021
+ // Projection node lifecycle + the `onProjectionUpdate` listener.
1022
+ // Mount once the element binds; seed the baseline layout; unmount on
1023
+ // cleanup. Depends ONLY on `element` — the `onProjectionUpdate`
1024
+ // subscription lives in its own effect below so a change to the
1025
+ // callback's identity re-subscribes WITHOUT tearing the node down
1026
+ // (an unmount would clear latestLayout/children and re-seed the
1027
+ // first commit instead of emitting a real delta).
1028
+ $effect(() => {
1029
+ if (!element) return
1030
+ projection.mount(element)
1031
+ projection.measure() // seed latestLayout so the first commit can diff
1032
+ return () => {
1033
+ projection.unmount()
1034
+ }
1035
+ })
1036
+
1037
+ // Subscribe the consumer's `onProjectionUpdate` callback. Separate
1038
+ // from the mount effect so re-subscribing on a callback-identity
1039
+ // change never unmounts the node.
1040
+ $effect(() => {
1041
+ if (!(element && onProjectionUpdateProp)) return
1042
+ const off = projection.addEventListener('didUpdate', (data) => onProjectionUpdateProp(data))
1043
+ return () => {
1044
+ off()
1045
+ }
1046
+ })
1047
+
756
1048
  // Minimal layout animation using FLIP when `layout` is enabled.
757
1049
  // When layout === 'position' we only translate.
758
1050
  // When layout === true we also scale to smoothly interpolate size changes.
759
- let lastRect: DOMRect | null = null
1051
+ let lastRect: RectLike | null = null
760
1052
  $effect(() => {
761
1053
  if (!(element && layoutProp && isLoaded === 'ready')) return
762
1054
 
763
- // Initialize last rect on first ready frame
764
- lastRect = measureRect(element!, resolveLayoutScrollAncestors())
1055
+ // Initialize last rect on first ready frame. We measure through the
1056
+ // projection node rather than `measureRect` directly so the rect is
1057
+ // ancestor-transform-invariant: the node resets the whole ancestor
1058
+ // chain to its mount-time base while reading, which strips any
1059
+ // motion-applied (FLIP/drag) transform up the tree. Without this a
1060
+ // child re-measures and FLIPs whenever an ancestor's transform
1061
+ // animates, even though the child's own layout never moved. (#379)
1062
+ lastRect = measureLayoutRect()
765
1063
  // Hint compositor for smoother FLIP transforms
766
1064
  setCompositorHints(element!, true)
767
1065
 
768
1066
  let rafId: number | null = null
769
1067
  const runFlip = () => {
770
- const scrollContainers = resolveLayoutScrollAncestors()
1068
+ // commitLayoutChange does the ancestor-stripped measure, fans
1069
+ // the delta out to `onProjectionUpdate` listeners, AND returns
1070
+ // the freshly-measured box — so the FLIP reuses that single
1071
+ // measurement as `next` instead of walking + transform-resetting
1072
+ // the ancestor chain a second time per frame. (#379)
1073
+ const nextBox = projection.commitLayoutChange()
1074
+ if (!nextBox) return
1075
+ const next = boxToRectLike(nextBox)
771
1076
  if (!lastRect) {
772
- lastRect = measureRect(element!, scrollContainers)
1077
+ lastRect = next
773
1078
  return
774
1079
  }
775
- const next = measureRect(element!, scrollContainers)
776
1080
  const transforms = computeFlipTransforms(lastRect, next, layoutProp ?? false)
777
1081
  runFlipAnimation(element!, transforms, (mergedTransition ?? {}) as AnimationOptions)
778
1082
  lastRect = next
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export { motion } from './motion';
6
6
  export { animate, delay, hover, inView, press, resize, scroll, stagger, transform } from 'motion';
7
7
  export { anticipate, backIn, backInOut, backOut, circIn, circInOut, circOut, cubicBezier, easeIn, easeInOut, easeOut } from 'motion';
8
8
  export { clamp, distance, distance2D, interpolate, mix, pipe, progress, wrap } from 'motion';
9
- export type { DragAxis, DragConstraints, DragControls, DragInfo, DragTransition, MotionAnimate, MotionInitial, MotionOnDirectionLock, MotionOnDragTransitionEnd, MotionOnInViewEnd, MotionOnInViewStart, MotionTransition, MotionWhileDrag, MotionWhileFocus, MotionWhileHover, MotionWhileInView, MotionWhileTap, ReducedMotionConfig, Variants } from './types';
9
+ export type { DragAxis, DragConstraints, DragControls, DragInfo, DragTransition, MotionAnimate, MotionInitial, MotionOnDirectionLock, MotionOnDragTransitionEnd, MotionOnInViewEnd, MotionOnInViewStart, MotionOnProjectionUpdate, MotionTransition, MotionWhileDrag, MotionWhileFocus, MotionWhileHover, MotionWhileInView, MotionWhileTap, ProjectionUpdatePayload, ReducedMotionConfig, Variants } from './types';
10
10
  export { useAnimate } from './utils/animate.svelte';
11
11
  export type { AnimationScope } from './utils/animate.svelte';
12
12
  export { useAnimationFrame } from './utils/animationFrame';
package/dist/types.d.ts CHANGED
@@ -246,6 +246,68 @@ export type MotionOnDrag = ((event: PointerEvent, info: DragInfo) => void) | und
246
246
  export type MotionOnDragEnd = ((event: PointerEvent, info: DragInfo) => void) | undefined;
247
247
  export type MotionOnDirectionLock = ((axis: 'x' | 'y') => void) | undefined;
248
248
  export type MotionOnDragTransitionEnd = (() => void) | undefined;
249
+ /**
250
+ * Pan-gesture callbacks. PanInfo is structurally identical to DragInfo
251
+ * (`{ point, delta, offset, velocity }`), so we re-use the type rather
252
+ * than ship a parallel alias.
253
+ */
254
+ export type MotionOnPanSessionStart = ((event: PointerEvent, info: DragInfo) => void) | undefined;
255
+ export type MotionOnPanStart = ((event: PointerEvent, info: DragInfo) => void) | undefined;
256
+ export type MotionOnPan = ((event: PointerEvent, info: DragInfo) => void) | undefined;
257
+ export type MotionOnPanEnd = ((event: PointerEvent, info: DragInfo) => void) | undefined;
258
+ /**
259
+ * Payload delivered to `onProjectionUpdate`. Re-declared structurally
260
+ * here (rather than imported from `projection.ts`) so `types.ts`
261
+ * stays dependency-free at the type layer. `delta.x/y.translate` is the
262
+ * px shift the element's layout box moved between the pre-change
263
+ * snapshot and the post-change measurement; `hasLayoutChanged` is false
264
+ * only when the delta is within a tight float epsilon (±0.01px
265
+ * translate), so even a sub-pixel real move is reported as a change.
266
+ */
267
+ export type ProjectionUpdatePayload = {
268
+ layout: {
269
+ x: {
270
+ min: number;
271
+ max: number;
272
+ };
273
+ y: {
274
+ min: number;
275
+ max: number;
276
+ };
277
+ };
278
+ snapshot: {
279
+ x: {
280
+ min: number;
281
+ max: number;
282
+ };
283
+ y: {
284
+ min: number;
285
+ max: number;
286
+ };
287
+ };
288
+ delta: {
289
+ x: {
290
+ translate: number;
291
+ scale: number;
292
+ origin: number;
293
+ originPoint: number;
294
+ };
295
+ y: {
296
+ translate: number;
297
+ scale: number;
298
+ origin: number;
299
+ originPoint: number;
300
+ };
301
+ };
302
+ hasLayoutChanged: boolean;
303
+ };
304
+ /**
305
+ * Fires after each layout change to a `motion.*` element that has
306
+ * `layout` enabled, with the FLIP delta between the pre- and
307
+ * post-change layout boxes. Mirrors framer-motion's `onLayoutMeasure`
308
+ * surface. Wired through the element's internal `ProjectionNode`.
309
+ */
310
+ export type MotionOnProjectionUpdate = ((data: ProjectionUpdatePayload) => void) | undefined;
249
311
  export type DragAxis = boolean | 'x' | 'y';
250
312
  export type DragConstraints = {
251
313
  top?: number;
@@ -319,6 +381,8 @@ export type MotionProps = {
319
381
  whileFocus?: MotionWhileFocus;
320
382
  /** Drag interaction animation */
321
383
  whileDrag?: MotionWhileDrag;
384
+ /** Pan interaction animation — applied while a pan gesture is active */
385
+ whilePan?: MotionWhileDrag;
322
386
  /** In-view interaction animation - animates when element enters viewport */
323
387
  whileInView?: MotionWhileInView;
324
388
  /** IntersectionObserver options for `whileInView` (once / root / margin / amount) */
@@ -355,12 +419,26 @@ export type MotionProps = {
355
419
  onDirectionLock?: MotionOnDirectionLock;
356
420
  /** Called when the post-drag transition finishes on all axes */
357
421
  onDragTransitionEnd?: MotionOnDragTransitionEnd;
422
+ /** Pan gesture: fires once per pointerdown, before threshold */
423
+ onPanSessionStart?: MotionOnPanSessionStart;
424
+ /** Pan gesture: fires the first frame after offset crosses threshold */
425
+ onPanStart?: MotionOnPanStart;
426
+ /** Pan gesture: fires once per frame while panning */
427
+ onPan?: MotionOnPan;
428
+ /** Pan gesture: fires on pointerup if onPanStart ever fired */
429
+ onPanEnd?: MotionOnPanEnd;
358
430
  /** Inline styles */
359
431
  style?: string;
360
432
  /** CSS classes */
361
433
  class?: string;
362
434
  /** Enable FLIP layout animations; "position" limits to translation only */
363
435
  layout?: boolean | 'position';
436
+ /**
437
+ * Fires after each `layout`-driven change with the FLIP delta from
438
+ * the element's internal projection node. Mirrors framer-motion's
439
+ * `onLayoutMeasure`. Requires `layout` to be enabled.
440
+ */
441
+ onProjectionUpdate?: MotionOnProjectionUpdate;
364
442
  /** Shared layout animation identifier. Elements with matching layoutId animate between positions. */
365
443
  layoutId?: string;
366
444
  /**