@humanspeak/svelte-motion 0.5.0 → 0.5.2

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.
@@ -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 {
@@ -48,6 +52,7 @@
48
52
  } from '../utils/presence'
49
53
  import { getInitialKeyframes } from '../utils/initial'
50
54
  import { attachDrag } from '../utils/drag'
55
+ import { attachPan, type AttachPanCleanup } from '../utils/pan'
51
56
  import { resolveInitial, resolveAnimate, resolveExit, resolveWhile } from '../utils/variants'
52
57
  import {
53
58
  setVariantContext,
@@ -97,6 +102,11 @@
97
102
  whileInView: whileInViewProp,
98
103
  viewport: viewportProp,
99
104
  whileDrag: whileDragProp,
105
+ whilePan: whilePanProp,
106
+ onPanSessionStart: onPanSessionStartProp,
107
+ onPanStart: onPanStartProp,
108
+ onPan: onPanProp,
109
+ onPanEnd: onPanEndProp,
100
110
  onHoverStart: onHoverStartProp,
101
111
  onHoverEnd: onHoverEndProp,
102
112
  onFocusStart: onFocusStartProp,
@@ -455,6 +465,7 @@
455
465
  const resolvedWhileHover = $derived(resolveWhile(whileHoverProp, variantsProp, effectiveCustom))
456
466
  const resolvedWhileFocus = $derived(resolveWhile(whileFocusProp, variantsProp, effectiveCustom))
457
467
  const resolvedWhileDrag = $derived(resolveWhile(whileDragProp, variantsProp, effectiveCustom))
468
+ const resolvedWhilePan = $derived(resolveWhile(whilePanProp, variantsProp, effectiveCustom))
458
469
  const resolvedWhileInView = $derived(
459
470
  resolveWhile(whileInViewProp, variantsProp, effectiveCustom)
460
471
  )
@@ -594,6 +605,172 @@
594
605
  }
595
606
  })
596
607
 
608
+ /**
609
+ * Pan-gesture wiring. Active whenever any of `onPanSessionStart`,
610
+ * `onPanStart`, `onPan`, `onPanEnd`, or `whilePan` is set. Unlike
611
+ * `drag`, Pan has no constraints / momentum / origin-snap — it's a
612
+ * pure pointer offset+velocity reporter, useful for swipe-to-dismiss
613
+ * sheets, custom carousels, and any "tell me what the gesture is
614
+ * doing right now" interaction. Mirrors framer-motion's `PanGesture`
615
+ * (packages/framer-motion/src/gestures/pan/index.ts).
616
+ *
617
+ * Split into TWO effects:
618
+ *
619
+ * 1. `attach` — keyed on `element`, `isLoaded === 'ready'`, presence
620
+ * of any pan handler/whilePan, and absence of `drag` (drag takes
621
+ * precedence — upstream framer-motion routes drag THROUGH the pan
622
+ * gesture internally, so co-attaching pan when drag is on would
623
+ * fight transforms). Creates / tears down the underlying
624
+ * `attachPan` lifetime once per element-bound interval.
625
+ *
626
+ * 2. `swap` — keyed on the user's handler/whilePan props. Calls
627
+ * `teardownPan.update(next)` to hot-swap the live handler set
628
+ * without destroying the in-flight `PanSession`. Without this
629
+ * split, every parent re-render that produces a fresh inline
630
+ * arrow handler would tear down the live gesture mid-pan —
631
+ * pointer listeners removed, no `onPanEnd` ever fires, whilePan
632
+ * keyframes leak.
633
+ */
634
+ let teardownPan: AttachPanCleanup | null = null
635
+ let activeWhilePanKeyframes: Record<string, unknown> | null = null
636
+ let whilePanBaseline: Record<string, unknown> | null = null
637
+
638
+ /**
639
+ * Boolean presence-check for "is any pan surface active?". Derived
640
+ * so the attach effect below tracks the *boolean value*, not the
641
+ * individual handler/whilePan reference identities. A consumer
642
+ * passing `onPan={(e, i) => ...}` (inline arrow — fresh ref every
643
+ * render) used to re-trigger the attach effect on every parent
644
+ * render; with this derived in place, the attach effect only
645
+ * re-runs when overall presence flips (none → some, some → none).
646
+ * Per-ref changes flow through the hot-swap effect instead.
647
+ */
648
+ const hasAnyPanHandler = $derived(
649
+ !!onPanProp ||
650
+ !!onPanStartProp ||
651
+ !!onPanEndProp ||
652
+ !!onPanSessionStartProp ||
653
+ !!resolvedWhilePan
654
+ )
655
+
656
+ const buildPanHandlers = (): {
657
+ onSessionStart?: MotionOnPanSessionStart
658
+ onStart: NonNullable<MotionOnPanStart>
659
+ onMove?: MotionOnPan
660
+ onEnd: NonNullable<MotionOnPanEnd>
661
+ } => ({
662
+ onSessionStart: onPanSessionStartProp,
663
+ onStart: (event, info) => {
664
+ if (resolvedWhilePan && element) {
665
+ // Snapshot the values we'll revert to BEFORE applying — same
666
+ // `computeHoverBaseline` path the other while-* gestures
667
+ // (whileHover/whileFocus/drag) use. Covers animatable transform
668
+ // shorthands (scale, rotate, x, y) AND restores non-animatable
669
+ // inline writes (cursor, pointer-events) since the baseline
670
+ // sniffs `animate` → `initial` → computed style → inline style.
671
+ whilePanBaseline = computeHoverBaseline(element, {
672
+ initial: (initialKeyframes ?? {}) as Record<string, unknown>,
673
+ animate: (resolvedAnimate ?? {}) as Record<string, unknown>,
674
+ whileHover: (resolvedWhilePan ?? {}) as Record<string, unknown>
675
+ })
676
+ const { keyframes, transition } = splitHoverDefinition(
677
+ resolvedWhilePan as Record<string, unknown>
678
+ )
679
+ activeWhilePanKeyframes = keyframes
680
+ animateWithLifecycle(
681
+ element,
682
+ keyframes as unknown as DOMKeyframesDefinition,
683
+ (transition ?? mergedTransition ?? {}) as unknown as AnimationOptions
684
+ )
685
+ }
686
+ onPanStartProp?.(event, info)
687
+ },
688
+ onMove: onPanProp,
689
+ onEnd: (event, info) => {
690
+ if (activeWhilePanKeyframes && whilePanBaseline && element) {
691
+ animateWithLifecycle(
692
+ element,
693
+ whilePanBaseline as unknown as DOMKeyframesDefinition,
694
+ (mergedTransition ?? {}) as unknown as AnimationOptions
695
+ )
696
+ }
697
+ activeWhilePanKeyframes = null
698
+ whilePanBaseline = null
699
+ onPanEndProp?.(event, info)
700
+ }
701
+ })
702
+
703
+ $effect(() => {
704
+ if (isPlaywright) {
705
+ pwLog('[motion] pan attach effect run', {
706
+ hasAnyPanHandler,
707
+ isLoaded
708
+ })
709
+ }
710
+ if (!element) return
711
+ // Defer attachment until the element has settled out of the enter
712
+ // animation phase — matches the gate every other gesture effect
713
+ // in this file uses (drag, whileTap, whileHover, whileFocus,
714
+ // whileInView). Without this, a pointerdown during the
715
+ // initial / mounting phase would attach pan listeners against an
716
+ // element whose enter animation hasn't committed its baseline.
717
+ if (isLoaded !== 'ready') return
718
+ // Drag takes precedence — upstream framer-motion's drag gesture is
719
+ // implemented ON TOP of Pan, not alongside it. Co-attaching here
720
+ // would create two competing pointer pipelines fighting for the
721
+ // same transforms.
722
+ if (dragProp) return
723
+ if (!hasAnyPanHandler) return
724
+
725
+ // `untrack` so the reactive reads inside `buildPanHandlers`
726
+ // (onPan*Prop, resolvedWhilePan, initialKeyframes, resolvedAnimate,
727
+ // mergedTransition) don't register as dependencies of this attach
728
+ // effect. Otherwise every parent re-render that passes a fresh
729
+ // inline arrow handler would re-run this effect and call
730
+ // `teardownPan?.()`, killing the live PanSession mid-gesture.
731
+ // Handler-ref changes flow exclusively through the hot-swap
732
+ // effect below, which calls `teardownPan.update(next)` — that's
733
+ // the path that keeps an in-flight gesture alive across re-renders.
734
+ teardownPan = attachPan(
735
+ element,
736
+ untrack(() => buildPanHandlers())
737
+ )
738
+
739
+ return () => {
740
+ // Synchronous revert of whilePan + lifecycle dispatch lives in
741
+ // attachPan.teardown() — the cleanup chain there calls
742
+ // session.dispatchTerminal(rawHandlers) BEFORE flipping isAlive,
743
+ // so onPanEnd fires (which runs the revert above) before the
744
+ // listeners go. dispatchTerminal is idempotent (PanSession's
745
+ // terminalDispatched flag) so a host that tears down after a
746
+ // natural release won't replay the lifecycle pair.
747
+ teardownPan?.()
748
+ teardownPan = null
749
+ activeWhilePanKeyframes = null
750
+ whilePanBaseline = null
751
+ }
752
+ })
753
+
754
+ /**
755
+ * Hot-swap effect — propagates handler / whilePan changes onto the
756
+ * existing PanSession via `teardownPan.update(next)`. Tracked
757
+ * separately from the attach effect so a fresh inline-arrow handler
758
+ * reference does NOT trigger teardown + re-attach. Without this
759
+ * split, every parent re-render mid-gesture would silently kill the
760
+ * live pan session.
761
+ */
762
+ $effect(() => {
763
+ // Track every prop the handler set depends on so this effect
764
+ // re-runs when any of them change.
765
+ void onPanSessionStartProp
766
+ void onPanStartProp
767
+ void onPanProp
768
+ void onPanEndProp
769
+ void resolvedWhilePan
770
+ if (!teardownPan) return
771
+ teardownPan.update(buildPanHandlers())
772
+ })
773
+
597
774
  /**
598
775
  * Execute the actual animation without wait mode checks.
599
776
  */
package/dist/index.d.ts CHANGED
@@ -14,6 +14,8 @@ export type { AugmentedMotionValue } from './utils/augmentMotionValue.svelte';
14
14
  export { useCycle } from './utils/cycle.svelte';
15
15
  export type { Cycle, CycleState } from './utils/cycle.svelte';
16
16
  export { createDragControls } from './utils/dragControls';
17
+ export { useFollowValue } from './utils/followValue.svelte';
18
+ export type { FollowMotionValue, UseFollowValueOptions } from './utils/followValue.svelte';
17
19
  export { useInView } from './utils/inView.svelte';
18
20
  export type { InViewState, UseInViewOptions } from './utils/inView.svelte';
19
21
  export { useMotionTemplate } from './utils/motionTemplate.svelte';
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ export { useAnimate } from './utils/animate.svelte';
13
13
  export { useAnimationFrame } from './utils/animationFrame';
14
14
  export { useCycle } from './utils/cycle.svelte';
15
15
  export { createDragControls } from './utils/dragControls';
16
+ export { useFollowValue } from './utils/followValue.svelte';
16
17
  export { useInView } from './utils/inView.svelte';
17
18
  export { useMotionTemplate } from './utils/motionTemplate.svelte';
18
19
  export { useMotionValue } from './utils/motionValue.svelte';
package/dist/types.d.ts CHANGED
@@ -246,6 +246,15 @@ 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;
249
258
  export type DragAxis = boolean | 'x' | 'y';
250
259
  export type DragConstraints = {
251
260
  top?: number;
@@ -319,6 +328,8 @@ export type MotionProps = {
319
328
  whileFocus?: MotionWhileFocus;
320
329
  /** Drag interaction animation */
321
330
  whileDrag?: MotionWhileDrag;
331
+ /** Pan interaction animation — applied while a pan gesture is active */
332
+ whilePan?: MotionWhileDrag;
322
333
  /** In-view interaction animation - animates when element enters viewport */
323
334
  whileInView?: MotionWhileInView;
324
335
  /** IntersectionObserver options for `whileInView` (once / root / margin / amount) */
@@ -355,6 +366,14 @@ export type MotionProps = {
355
366
  onDirectionLock?: MotionOnDirectionLock;
356
367
  /** Called when the post-drag transition finishes on all axes */
357
368
  onDragTransitionEnd?: MotionOnDragTransitionEnd;
369
+ /** Pan gesture: fires once per pointerdown, before threshold */
370
+ onPanSessionStart?: MotionOnPanSessionStart;
371
+ /** Pan gesture: fires the first frame after offset crosses threshold */
372
+ onPanStart?: MotionOnPanStart;
373
+ /** Pan gesture: fires once per frame while panning */
374
+ onPan?: MotionOnPan;
375
+ /** Pan gesture: fires on pointerup if onPanStart ever fired */
376
+ onPanEnd?: MotionOnPanEnd;
358
377
  /** Inline styles */
359
378
  style?: string;
360
379
  /** CSS classes */
@@ -0,0 +1,84 @@
1
+ import { type FollowValueOptions, type MotionValue } from 'motion-dom';
2
+ import { type Readable } from 'svelte/store';
3
+ import { type AugmentedMotionValue } from './augmentMotionValue.svelte.js';
4
+ /**
5
+ * Options accepted by {@link useFollowValue}. Mirrors framer-motion's
6
+ * `FollowValueOptions` 1:1 — any `ValueAnimationTransition` shape (spring /
7
+ * tween / inertia / keyframes) plus `skipInitialAnimation` for
8
+ * scroll-restoration scenarios.
9
+ *
10
+ * The shape is re-exported from motion-dom so consumers don't have to
11
+ * cross-import.
12
+ *
13
+ * @see https://motion.dev/docs/react-use-follow-value
14
+ */
15
+ export type UseFollowValueOptions = FollowValueOptions;
16
+ /**
17
+ * The augmented `MotionValue` returned by {@link useFollowValue} (and, since
18
+ * `useSpring` now delegates here, by `useSpring` too).
19
+ */
20
+ export type FollowMotionValue<T extends number | string> = AugmentedMotionValue<T>;
21
+ /**
22
+ * Creates an animated `MotionValue` that, when `.set(v)` is called, animates
23
+ * toward `v` using **any** transition type — spring, tween, inertia, or
24
+ * keyframes. Pass another `MotionValue` (or Svelte readable) as the source
25
+ * and the result follows it: every source emit kicks off a new animation
26
+ * toward the latest value.
27
+ *
28
+ * Mirrors React framer-motion's `useFollowValue` 1:1. `useSpring` in this
29
+ * library is now a thin wrapper that delegates here with the default
30
+ * `{ type: 'spring' }`.
31
+ *
32
+ * Returned object is a real motion-dom `MotionValue` (composes with
33
+ * `useTransform`, `useVelocity`, `animate()`, etc.) augmented with a
34
+ * `$state`-backed `.current` getter and a Svelte readable `.subscribe`
35
+ * shim.
36
+ *
37
+ * Lifecycle: must be called during component initialization. Cleanup is
38
+ * registered via `$effect`; the follow animation stops, the source bridge
39
+ * (if any) tears down, and `value.destroy()` runs when the surrounding
40
+ * `$effect` scope unmounts.
41
+ *
42
+ * SSR-safe: returns a static augmented `MotionValue` with no animation on
43
+ * the server. `.set` and `.jump` become no-ops to avoid drifting away from
44
+ * the server-rendered snapshot.
45
+ *
46
+ * @template T The value type — `number` or `string` (unit strings preserved).
47
+ * @param source Initial value, a `MotionValue` to follow, or a Svelte readable.
48
+ * @param options Transition + follow configuration (`type: 'spring' | 'tween' | 'inertia' | 'keyframes'`, plus the corresponding per-type options).
49
+ * @returns A `MotionValue<T>` with `.current` and `.subscribe`.
50
+ *
51
+ * @example
52
+ * ```svelte
53
+ * <script lang="ts">
54
+ * import { useFollowValue, useMotionValue } from '@humanspeak/svelte-motion'
55
+ *
56
+ * const target = useMotionValue(0)
57
+ *
58
+ * // Spring follow (default — same as `useSpring(target)`).
59
+ * const spring = useFollowValue(target, { stiffness: 300, damping: 30 })
60
+ *
61
+ * // Tween follow — eased linear interpolation.
62
+ * const eased = useFollowValue(target, {
63
+ * type: 'tween',
64
+ * duration: 0.4,
65
+ * ease: 'easeInOut'
66
+ * })
67
+ *
68
+ * // Inertia — decays from initial velocity.
69
+ * const drifting = useFollowValue(0, { type: 'inertia', velocity: 800, power: 0.6 })
70
+ * </script>
71
+ *
72
+ * <button onclick={() => target.set(target.get() === 0 ? 200 : 0)}>Toggle</button>
73
+ * <div style="transform: translateX({spring.current}px)">spring</div>
74
+ * <div style="transform: translateX({eased.current}px)">tween</div>
75
+ * <div style="transform: translateX({drifting.current}px)">inertia</div>
76
+ * ```
77
+ *
78
+ * @see https://motion.dev/docs/react-use-follow-value
79
+ */
80
+ export declare function useFollowValue(source: number, options?: UseFollowValueOptions): FollowMotionValue<number>;
81
+ export declare function useFollowValue(source: string, options?: UseFollowValueOptions): FollowMotionValue<string>;
82
+ export declare function useFollowValue(source: MotionValue<number>, options?: UseFollowValueOptions): FollowMotionValue<number>;
83
+ export declare function useFollowValue(source: MotionValue<string>, options?: UseFollowValueOptions): FollowMotionValue<string>;
84
+ export declare function useFollowValue<T extends number | string>(source: Readable<T>, options?: UseFollowValueOptions): FollowMotionValue<T>;
@@ -0,0 +1,51 @@
1
+ import { attachFollow, isMotionValue, motionValue } from 'motion-dom';
2
+ import {} from 'svelte/store';
3
+ import { augmentMotionValue, isSvelteReadable, sampleSource } from './augmentMotionValue.svelte.js';
4
+ export function useFollowValue(source, options = {}) {
5
+ // SSR: return a static MotionValue with no animation. Reads return the
6
+ // best-effort initial value; .set / .jump become no-ops to avoid drifting
7
+ // away from the server-rendered snapshot.
8
+ if (typeof window === 'undefined') {
9
+ const initial = sampleSource(source);
10
+ const ssrValue = motionValue(initial);
11
+ ssrValue.set = () => undefined;
12
+ ssrValue.jump = () => undefined;
13
+ return augmentMotionValue(ssrValue);
14
+ }
15
+ // Resolve initial + follow source. Svelte readables get bridged into a
16
+ // motion-dom MotionValue so `attachFollow` can subscribe to their emits.
17
+ let followSource;
18
+ let cleanupReadableBridge;
19
+ let svelteBridge;
20
+ if (isMotionValue(source)) {
21
+ followSource = source;
22
+ }
23
+ else if (isSvelteReadable(source)) {
24
+ const initialFromReadable = sampleSource(source);
25
+ svelteBridge = motionValue(initialFromReadable);
26
+ cleanupReadableBridge = source.subscribe((v) => {
27
+ // Svelte readable contract emits synchronously on subscribe. Skip
28
+ // the initial emit (already seeded above) so attachFollow doesn't
29
+ // fire an animation on attach.
30
+ if (svelteBridge.get() === v)
31
+ return;
32
+ svelteBridge.set(v);
33
+ });
34
+ followSource = svelteBridge;
35
+ }
36
+ else {
37
+ followSource = source;
38
+ }
39
+ const initial = isMotionValue(followSource) ? followSource.get() : followSource;
40
+ const value = motionValue(initial);
41
+ // Default transition is `spring` — matches React framer-motion's
42
+ // `useFollowValue` default. Caller's `type` (if set) overrides.
43
+ const stopFollow = attachFollow(value, followSource, { type: 'spring', ...options });
44
+ const dispose = () => {
45
+ stopFollow?.();
46
+ cleanupReadableBridge?.();
47
+ svelteBridge?.destroy();
48
+ };
49
+ $effect(() => () => value.destroy());
50
+ return augmentMotionValue(value, dispose);
51
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Pan gesture session.
3
+ *
4
+ * Direct port of framer-motion's `PanSession`
5
+ * (`packages/framer-motion/src/gestures/pan/PanSession.ts`) and `PanGesture`
6
+ * (`packages/framer-motion/src/gestures/pan/index.ts`). Pan is the
7
+ * primitive that powers swipe-to-dismiss drawers, swipe-to-delete rows,
8
+ * carousels, and any gesture that tracks pointer offset/velocity without
9
+ * the constraint/momentum/snap-to-origin baggage of `drag`.
10
+ *
11
+ * Critical design notes (mirrors upstream):
12
+ *
13
+ * - `pointermove`, `pointerup`, `pointercancel` subscribe on the
14
+ * `contextWindow` (defaults to `window`), NOT the source element. This
15
+ * keeps the gesture alive even when the pointer leaves the element's
16
+ * bounds during a fast swipe — the original element is only used for
17
+ * the initial `pointerdown` and for scroll-compensation tracking.
18
+ *
19
+ * - `distanceThreshold` (default `3`px) gates the `onStart` callback so
20
+ * a steady press without movement doesn't fire a pan. `onSessionStart`
21
+ * fires immediately on pointerdown for setup work.
22
+ *
23
+ * - Per-frame throttling via `frame.update(updatePoint, true)` so a flood
24
+ * of pointermove events doesn't run handlers more than once per render
25
+ * frame. On top of that, individual handlers are routed onto motion-dom's
26
+ * step lanes (see `wrapUpdate` / `wrapPostRender` above):
27
+ * `onSessionStart` / `onStart` / `onMove` land on `update`,
28
+ * `onEnd` / `onSessionEnd` on `postRender`. Matches upstream's
29
+ * `asyncHandler` + `frame.postRender` split byte-for-byte.
30
+ *
31
+ * - `getPanInfo` returns `{ point, delta, offset, velocity }` — identical
32
+ * shape to motion-dom's `DragInfo` / framer-motion's `PanInfo`.
33
+ *
34
+ * - Velocity uses a 100ms history window with the "skip the pointer-down
35
+ * origin if it's too stale" tweak upstream added for hold-then-flick
36
+ * gestures.
37
+ */
38
+ import type { DragInfo } from '../types';
39
+ export interface PanHandlers {
40
+ /** Fires on `pointerdown` regardless of whether movement follows. */
41
+ onSessionStart?: (event: PointerEvent, info: DragInfo) => void;
42
+ /** Fires the first time pointer offset crosses `distanceThreshold`. */
43
+ onStart?: (event: PointerEvent, info: DragInfo) => void;
44
+ /** Fires on every per-frame-throttled pointermove past threshold. */
45
+ onMove?: (event: PointerEvent, info: DragInfo) => void;
46
+ /** Fires on `pointerup` / `pointercancel` if `onStart` ever fired. */
47
+ onEnd?: (event: PointerEvent, info: DragInfo) => void;
48
+ /** Fires on `pointerup` / `pointercancel` always (paired with `onSessionStart`). */
49
+ onSessionEnd?: (event: PointerEvent, info: DragInfo) => void;
50
+ }
51
+ export interface AttachPanOptions {
52
+ /**
53
+ * Movement distance (in pixels) required before `onStart`/`onMove`
54
+ * fire. Default `3` — same as framer-motion. A steady press with
55
+ * sub-threshold drift is reported via `onSessionStart` / `onSessionEnd`
56
+ * only.
57
+ */
58
+ distanceThreshold?: number;
59
+ /**
60
+ * Window to attach the move/up/cancel listeners to. Defaults to the
61
+ * source element's owner window. Override for iframe / shadow-root
62
+ * scenarios.
63
+ */
64
+ contextWindow?: Window | null;
65
+ }
66
+ /**
67
+ * Cleanup function returned by `attachPan`. Carries an `update` method
68
+ * that hot-swaps the live handler set without tearing down the active
69
+ * `PanSession` — call this when a consumer's `onPan` reference changes
70
+ * mid-gesture (the canonical Svelte 5 pattern of inline arrow handlers
71
+ * passes a fresh closure every render). Without this, the host
72
+ * `$effect` would have to teardown + re-attach and the user's in-flight
73
+ * pan would silently die.
74
+ */
75
+ export type AttachPanCleanup = (() => void) & {
76
+ update: (next: PanHandlers) => void;
77
+ };
78
+ /**
79
+ * Attach a pan gesture session to `el`. Returns a cleanup function that
80
+ * tears down the pointerdown listener and ends any in-flight session,
81
+ * with a `.update(next)` method for hot-swapping handlers mid-gesture.
82
+ *
83
+ * Internally a fresh `PanSession` spawns on each pointerdown — the
84
+ * outer attachment just keeps the pointerdown listener alive across the
85
+ * element's lifetime.
86
+ *
87
+ * SSR-safe: returns a no-op cleanup if `window` is undefined. The Svelte
88
+ * `$effect` consumer never fires on the server anyway, but defending the
89
+ * boundary lets the module load cleanly in node-only test runners.
90
+ *
91
+ * Lifecycle guarantee: when the returned cleanup runs mid-gesture, the
92
+ * session synthesizes `onEnd` + `onSessionEnd` against the raw handlers
93
+ * BEFORE removing listeners (see `PanSession.dispatchTerminal`). Hosts
94
+ * (e.g. `_MotionContainer`'s pan `$effect`) can put their `whilePan`
95
+ * revert logic inside the user-supplied `onEnd` and rely on it firing
96
+ * exactly once per gesture — whether the user released or the host
97
+ * forced teardown.
98
+ *
99
+ * @param el Target element to bind `pointerdown` on. Move/up/cancel
100
+ * events are listened for on the element's owning window so a fast
101
+ * swipe past the element's bounds keeps the gesture alive.
102
+ * @param handlers Pan lifecycle handlers. Any subset of
103
+ * `onSessionStart` (fires on pointerdown), `onStart` (fires the first
104
+ * time the cumulative offset crosses `distanceThreshold`), `onMove`
105
+ * (per-frame-throttled on every pointermove past threshold), `onEnd`
106
+ * (fires on pointerup/cancel if `onStart` ever fired), `onSessionEnd`
107
+ * (fires on every pointerup/cancel where a pointermove occurred).
108
+ * @param options Per-session config. `distanceThreshold` (default 3px)
109
+ * gates the start callback; `contextWindow` overrides the owning
110
+ * window (use for shadow-root / iframe scenarios).
111
+ * @returns A cleanup function with an attached `.update(next)` method.
112
+ * Calling the cleanup ends the session + removes the pointerdown
113
+ * listener. Calling `.update(next)` swaps handlers in place on the
114
+ * live session without rebuilding it — the canonical Svelte pattern
115
+ * for inline arrow handlers that change identity each render.
116
+ *
117
+ * @example
118
+ * ```ts
119
+ * const cleanup = attachPan(node, {
120
+ * onStart: (_event, info) => console.log('start', info.offset),
121
+ * onMove: (_event, info) => x.set(info.offset.x),
122
+ * onEnd: (_event, info) => {
123
+ * if (Math.abs(info.velocity.x) > 600) commit()
124
+ * else animate(x, 0, { type: 'spring' })
125
+ * }
126
+ * })
127
+ *
128
+ * // Later, swap handlers without ending the live gesture:
129
+ * cleanup.update({ onMove: (_e, info) => x.set(info.offset.x * 2) })
130
+ *
131
+ * // On unmount:
132
+ * cleanup()
133
+ * ```
134
+ */
135
+ export declare const attachPan: (el: HTMLElement, handlers: PanHandlers, options?: AttachPanOptions) => AttachPanCleanup;
@@ -0,0 +1,426 @@
1
+ import { cancelFrame, frame, frameData, isPrimaryPointer } from 'motion-dom';
2
+ /**
3
+ * Brand we stamp on already-wrapped handlers so passing them back through
4
+ * `wrapHandlers` (e.g. by a future middleware layer) doesn't double-defer
5
+ * — that would compound frame latency invisibly per wrap depth. Symbol
6
+ * scoping keeps it private to this module.
7
+ */
8
+ const WRAPPED_BRAND = Symbol('svelte-motion:pan:wrapped');
9
+ const wrapUpdate = (handler, isAlive) => {
10
+ if (!handler)
11
+ return undefined;
12
+ if (handler[WRAPPED_BRAND])
13
+ return handler;
14
+ const wrapped = (event, info) => {
15
+ frame.update(() => {
16
+ // `isAlive` flips false on teardown; any frame.update closure
17
+ // queued before teardown but not yet flushed will see this and
18
+ // short-circuit — that's our cancellation path for the
19
+ // otherwise-uncancellable anonymous closures `frame.update`
20
+ // accepts.
21
+ if (!isAlive())
22
+ return;
23
+ handler(event, info);
24
+ }, false, true);
25
+ };
26
+ Object.defineProperty(wrapped, WRAPPED_BRAND, { value: true });
27
+ return wrapped;
28
+ };
29
+ const wrapPostRender = (handler, isAlive) => {
30
+ if (!handler)
31
+ return undefined;
32
+ if (handler[WRAPPED_BRAND])
33
+ return handler;
34
+ const wrapped = (event, info) => {
35
+ frame.postRender(() => {
36
+ if (!isAlive())
37
+ return;
38
+ handler(event, info);
39
+ });
40
+ };
41
+ Object.defineProperty(wrapped, WRAPPED_BRAND, { value: true });
42
+ return wrapped;
43
+ };
44
+ const wrapHandlers = (handlers, isAlive) => ({
45
+ onSessionStart: wrapUpdate(handlers.onSessionStart, isAlive),
46
+ onStart: wrapUpdate(handlers.onStart, isAlive),
47
+ onMove: wrapUpdate(handlers.onMove, isAlive),
48
+ onEnd: wrapPostRender(handlers.onEnd, isAlive),
49
+ onSessionEnd: wrapPostRender(handlers.onSessionEnd, isAlive)
50
+ });
51
+ const overflowStyles = new Set(['auto', 'scroll']);
52
+ const millisecondsToSeconds = (ms) => ms / 1000;
53
+ const secondsToMilliseconds = (s) => s * 1000;
54
+ const subtractPoint = (a, b) => ({
55
+ x: a.x - b.x,
56
+ y: a.y - b.y
57
+ });
58
+ const distance2D = (a, b) => Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2);
59
+ /**
60
+ * Compute velocity (px/s) from the history of timestamped points,
61
+ * looking back `timeDelta` seconds for stability. Matches upstream's
62
+ * `getVelocity` including the hold-then-flick safeguard (skip
63
+ * history[0] if it's > 2× timeDelta old AND there are alternatives).
64
+ */
65
+ const getVelocity = (history, timeDelta) => {
66
+ if (history.length < 2)
67
+ return { x: 0, y: 0 };
68
+ let i = history.length - 1;
69
+ let timestampedPoint = null;
70
+ const lastPoint = history[history.length - 1];
71
+ while (i >= 0) {
72
+ timestampedPoint = history[i];
73
+ if (lastPoint.timestamp - timestampedPoint.timestamp > secondsToMilliseconds(timeDelta)) {
74
+ break;
75
+ }
76
+ i--;
77
+ }
78
+ if (!timestampedPoint)
79
+ return { x: 0, y: 0 };
80
+ if (timestampedPoint === history[0] &&
81
+ history.length > 2 &&
82
+ lastPoint.timestamp - timestampedPoint.timestamp > secondsToMilliseconds(timeDelta) * 2) {
83
+ timestampedPoint = history[1];
84
+ }
85
+ const time = millisecondsToSeconds(lastPoint.timestamp - timestampedPoint.timestamp);
86
+ if (time === 0)
87
+ return { x: 0, y: 0 };
88
+ const v = {
89
+ x: (lastPoint.x - timestampedPoint.x) / time,
90
+ y: (lastPoint.y - timestampedPoint.y) / time
91
+ };
92
+ if (v.x === Infinity)
93
+ v.x = 0;
94
+ if (v.y === Infinity)
95
+ v.y = 0;
96
+ return v;
97
+ };
98
+ const getPanInfo = (point, history) => ({
99
+ point,
100
+ delta: subtractPoint(point, history[history.length - 1]),
101
+ offset: subtractPoint(point, history[0]),
102
+ velocity: getVelocity(history, 0.1)
103
+ });
104
+ const extractEventPoint = (event) => ({
105
+ x: event.pageX,
106
+ y: event.pageY
107
+ });
108
+ /**
109
+ * Attach a pan gesture session to `el`. Returns a cleanup function that
110
+ * tears down the pointerdown listener and ends any in-flight session,
111
+ * with a `.update(next)` method for hot-swapping handlers mid-gesture.
112
+ *
113
+ * Internally a fresh `PanSession` spawns on each pointerdown — the
114
+ * outer attachment just keeps the pointerdown listener alive across the
115
+ * element's lifetime.
116
+ *
117
+ * SSR-safe: returns a no-op cleanup if `window` is undefined. The Svelte
118
+ * `$effect` consumer never fires on the server anyway, but defending the
119
+ * boundary lets the module load cleanly in node-only test runners.
120
+ *
121
+ * Lifecycle guarantee: when the returned cleanup runs mid-gesture, the
122
+ * session synthesizes `onEnd` + `onSessionEnd` against the raw handlers
123
+ * BEFORE removing listeners (see `PanSession.dispatchTerminal`). Hosts
124
+ * (e.g. `_MotionContainer`'s pan `$effect`) can put their `whilePan`
125
+ * revert logic inside the user-supplied `onEnd` and rely on it firing
126
+ * exactly once per gesture — whether the user released or the host
127
+ * forced teardown.
128
+ *
129
+ * @param el Target element to bind `pointerdown` on. Move/up/cancel
130
+ * events are listened for on the element's owning window so a fast
131
+ * swipe past the element's bounds keeps the gesture alive.
132
+ * @param handlers Pan lifecycle handlers. Any subset of
133
+ * `onSessionStart` (fires on pointerdown), `onStart` (fires the first
134
+ * time the cumulative offset crosses `distanceThreshold`), `onMove`
135
+ * (per-frame-throttled on every pointermove past threshold), `onEnd`
136
+ * (fires on pointerup/cancel if `onStart` ever fired), `onSessionEnd`
137
+ * (fires on every pointerup/cancel where a pointermove occurred).
138
+ * @param options Per-session config. `distanceThreshold` (default 3px)
139
+ * gates the start callback; `contextWindow` overrides the owning
140
+ * window (use for shadow-root / iframe scenarios).
141
+ * @returns A cleanup function with an attached `.update(next)` method.
142
+ * Calling the cleanup ends the session + removes the pointerdown
143
+ * listener. Calling `.update(next)` swaps handlers in place on the
144
+ * live session without rebuilding it — the canonical Svelte pattern
145
+ * for inline arrow handlers that change identity each render.
146
+ *
147
+ * @example
148
+ * ```ts
149
+ * const cleanup = attachPan(node, {
150
+ * onStart: (_event, info) => console.log('start', info.offset),
151
+ * onMove: (_event, info) => x.set(info.offset.x),
152
+ * onEnd: (_event, info) => {
153
+ * if (Math.abs(info.velocity.x) > 600) commit()
154
+ * else animate(x, 0, { type: 'spring' })
155
+ * }
156
+ * })
157
+ *
158
+ * // Later, swap handlers without ending the live gesture:
159
+ * cleanup.update({ onMove: (_e, info) => x.set(info.offset.x * 2) })
160
+ *
161
+ * // On unmount:
162
+ * cleanup()
163
+ * ```
164
+ */
165
+ export const attachPan = (el, handlers, options = {}) => {
166
+ if (typeof window === 'undefined') {
167
+ const noop = () => { };
168
+ return Object.assign(noop, { update: () => { } });
169
+ }
170
+ const contextWindow = options.contextWindow ?? el.ownerDocument?.defaultView ?? window;
171
+ const distanceThreshold = options.distanceThreshold ?? 3;
172
+ let session = null;
173
+ let rawHandlers = handlers;
174
+ // Liveness flag the wrapped handler closures consult before invoking
175
+ // the user callback. Flips false at teardown so any frame.update /
176
+ // frame.postRender callbacks queued before teardown — but not yet
177
+ // flushed — see the flag and skip dispatch. This is our only way to
178
+ // cancel the anonymous closures the wrappers schedule (frame.update
179
+ // doesn't return a handle we can store per call).
180
+ let isAlive = true;
181
+ const aliveGuard = () => isAlive;
182
+ // Frame-scheduled mirror of the live handlers — onSessionStart / onStart /
183
+ // onMove are queued onto motion-dom's `update` step, onEnd / onSessionEnd
184
+ // onto `postRender`. This is the wrap upstream applies via `asyncHandler`
185
+ // + `frame.postRender` in PanGesture.createPanHandlers; see the
186
+ // wrapUpdate / wrapPostRender helpers at the top of this file for the
187
+ // rationale. PanSession itself stays scheduler-unaware (and synchronous,
188
+ // for testability) — the scheduling lives at the `attachPan` boundary.
189
+ let liveHandlers = wrapHandlers(handlers, aliveGuard);
190
+ const onPointerDown = (event) => {
191
+ // Match upstream: ignore non-primary pointers (multi-touch, right-click).
192
+ if (!isPrimaryPointer(event))
193
+ return;
194
+ // Defensively end any prior session before overwriting the reference.
195
+ // Without this, a second primary pointerdown that arrives before the
196
+ // first pointerup orphans the prior session's contextWindow listeners.
197
+ session?.end();
198
+ session = new PanSession(event, liveHandlers, {
199
+ distanceThreshold,
200
+ contextWindow,
201
+ element: el
202
+ });
203
+ };
204
+ el.addEventListener('pointerdown', onPointerDown);
205
+ const update = (next) => {
206
+ rawHandlers = next;
207
+ liveHandlers = wrapHandlers(next, aliveGuard);
208
+ session?.updateHandlers(liveHandlers);
209
+ };
210
+ const teardown = () => {
211
+ // Synthesize the gesture's terminal lifecycle BEFORE flipping
212
+ // `isAlive`, so a host that tears us down mid-pan (effect re-run,
213
+ // component unmount) still sees a balanced onPanEnd / onPanSessionEnd
214
+ // pair. Dispatched against the *raw* (unwrapped) handlers so the
215
+ // delivery is synchronous — the wrapped lane would otherwise queue
216
+ // the callbacks onto frame.postRender just for them to be cancelled
217
+ // by the `isAlive = false` line immediately below.
218
+ if (session) {
219
+ session.dispatchTerminal(rawHandlers);
220
+ session.end();
221
+ session = null;
222
+ }
223
+ isAlive = false;
224
+ el.removeEventListener('pointerdown', onPointerDown);
225
+ };
226
+ return Object.assign(teardown, { update });
227
+ };
228
+ class PanSession {
229
+ history = [];
230
+ startEvent = null;
231
+ lastMoveEvent = null;
232
+ lastMovePoint = null;
233
+ handlers = {};
234
+ contextWindow = window;
235
+ distanceThreshold = 3;
236
+ element = null;
237
+ scrollPositions = new Map();
238
+ /**
239
+ * Idempotency flag — set the first time the gesture's terminal
240
+ * lifecycle pair (`onEnd` + `onSessionEnd`) fires. Both
241
+ * `handlePointerUp` (the natural release path) and
242
+ * `dispatchTerminal` (the forced-teardown path called by
243
+ * `attachPan.teardown`) check this and bail if already dispatched.
244
+ * Without it, a normal pointerup followed by a host-side teardown
245
+ * (e.g. `$effect` cleanup, component unmount) would replay
246
+ * `onEnd`/`onSessionEnd` against handlers that already saw them.
247
+ */
248
+ terminalDispatched = false;
249
+ removeScrollListeners = null;
250
+ removeListeners = null;
251
+ constructor(event, handlers, opts) {
252
+ // Bail on non-primary pointers. Properties keep their declared
253
+ // defaults so TypeScript sees them initialized regardless of which
254
+ // constructor branch ran.
255
+ if (!isPrimaryPointer(event))
256
+ return;
257
+ this.handlers = handlers;
258
+ this.contextWindow = opts.contextWindow;
259
+ this.distanceThreshold = opts.distanceThreshold;
260
+ this.element = opts.element;
261
+ const point = extractEventPoint(event);
262
+ this.history = [{ ...point, timestamp: frameData.timestamp }];
263
+ this.handlers.onSessionStart?.(event, getPanInfo(point, this.history));
264
+ const moveHandler = (e) => this.handlePointerMove(e);
265
+ const upHandler = (e) => this.handlePointerUp(e);
266
+ this.contextWindow.addEventListener('pointermove', moveHandler);
267
+ this.contextWindow.addEventListener('pointerup', upHandler);
268
+ this.contextWindow.addEventListener('pointercancel', upHandler);
269
+ this.removeListeners = () => {
270
+ this.contextWindow.removeEventListener('pointermove', moveHandler);
271
+ this.contextWindow.removeEventListener('pointerup', upHandler);
272
+ this.contextWindow.removeEventListener('pointercancel', upHandler);
273
+ };
274
+ if (this.element)
275
+ this.startScrollTracking(this.element);
276
+ }
277
+ updateHandlers(handlers) {
278
+ this.handlers = handlers;
279
+ }
280
+ end() {
281
+ this.removeListeners?.();
282
+ this.removeListeners = null;
283
+ this.removeScrollListeners?.();
284
+ this.removeScrollListeners = null;
285
+ this.scrollPositions.clear();
286
+ cancelFrame(this.updatePoint);
287
+ }
288
+ /**
289
+ * Synthesize the gesture's terminal lifecycle pair (`onEnd` then
290
+ * `onSessionEnd`) against the supplied *raw* (unwrapped) handlers,
291
+ * using the last observed event + point as the synthetic terminal
292
+ * sample. Called by `attachPan.teardown` when a host kills the
293
+ * session mid-gesture — without this, an `$effect` re-run that
294
+ * tears down the attachment silently strands the consumer's state
295
+ * machine in an "in-progress" state (whilePan keyframes never
296
+ * revert, threshold-based commit decisions never run).
297
+ *
298
+ * Bypasses the frame-loop wrappers deliberately: the wrapped
299
+ * handlers would queue to `frame.postRender` only for the
300
+ * about-to-flip `isAlive` flag in attachPan to cancel them. Raw
301
+ * dispatch keeps the lifecycle synchronous with teardown.
302
+ *
303
+ * No-op when no pointermove ever fired — matches the
304
+ * `handlePointerUp` no-movement contract upstream uses.
305
+ */
306
+ dispatchTerminal(rawHandlers) {
307
+ if (this.terminalDispatched)
308
+ return;
309
+ if (!(this.lastMoveEvent && this.lastMovePoint))
310
+ return;
311
+ const info = getPanInfo(this.lastMovePoint, this.history);
312
+ if (this.startEvent)
313
+ rawHandlers.onEnd?.(this.lastMoveEvent, info);
314
+ rawHandlers.onSessionEnd?.(this.lastMoveEvent, info);
315
+ this.terminalDispatched = true;
316
+ }
317
+ handlePointerMove = (event) => {
318
+ this.lastMoveEvent = event;
319
+ this.lastMovePoint = extractEventPoint(event);
320
+ // Per-frame throttle so a 1000hz mouse doesn't drown handlers.
321
+ frame.update(this.updatePoint, true);
322
+ };
323
+ handlePointerUp = (event) => {
324
+ this.end();
325
+ if (this.terminalDispatched)
326
+ return;
327
+ if (!(this.lastMoveEvent && this.lastMovePoint)) {
328
+ // No pointermove ever fired — match upstream framer-motion
329
+ // (`packages/framer-motion/src/gestures/pan/PanSession.ts`
330
+ // ~line 320) and return WITHOUT firing onEnd / onSessionEnd.
331
+ // Consumers that want a "tap" signal should use the press /
332
+ // tap gesture instead. This prevents a spurious
333
+ // onPanSessionStart → onPanSessionEnd pair on every plain
334
+ // click of a pan-enabled element.
335
+ return;
336
+ }
337
+ const finalPoint = event.type === 'pointercancel' ? this.lastMovePoint : extractEventPoint(event);
338
+ const info = getPanInfo(finalPoint, this.history);
339
+ if (this.startEvent)
340
+ this.handlers.onEnd?.(event, info);
341
+ this.handlers.onSessionEnd?.(event, info);
342
+ // Mark idempotent so a later forced teardown via
343
+ // `dispatchTerminal` doesn't replay this pair.
344
+ this.terminalDispatched = true;
345
+ };
346
+ updatePoint = () => {
347
+ if (!(this.lastMoveEvent && this.lastMovePoint))
348
+ return;
349
+ const info = getPanInfo(this.lastMovePoint, this.history);
350
+ const panAlreadyStarted = this.startEvent !== null;
351
+ const pastThreshold = distance2D(info.offset, { x: 0, y: 0 }) >= this.distanceThreshold;
352
+ if (!panAlreadyStarted && !pastThreshold)
353
+ return;
354
+ this.history.push({ ...this.lastMovePoint, timestamp: frameData.timestamp });
355
+ if (!panAlreadyStarted) {
356
+ this.handlers.onStart?.(this.lastMoveEvent, info);
357
+ this.startEvent = this.lastMoveEvent;
358
+ }
359
+ this.handlers.onMove?.(this.lastMoveEvent, info);
360
+ };
361
+ /**
362
+ * Track scrollable ancestors so we can compensate for scroll deltas
363
+ * during the gesture — mirrors upstream's `startScrollTracking`.
364
+ * For element scrolls: adjust `history[0]` so offset stays sane
365
+ * (pageX/pageY unaffected by element scroll). For window scrolls:
366
+ * adjust `lastMovePoint` (pageX/pageY shift with window scroll).
367
+ */
368
+ startScrollTracking(element) {
369
+ let current = element.parentElement;
370
+ while (current) {
371
+ const style = getComputedStyle(current);
372
+ if (overflowStyles.has(style.overflowX) || overflowStyles.has(style.overflowY)) {
373
+ this.scrollPositions.set(current, {
374
+ x: current.scrollLeft,
375
+ y: current.scrollTop
376
+ });
377
+ }
378
+ current = current.parentElement;
379
+ }
380
+ this.scrollPositions.set(this.contextWindow, {
381
+ x: this.contextWindow.scrollX,
382
+ y: this.contextWindow.scrollY
383
+ });
384
+ const onElementScroll = (event) => {
385
+ this.handleScroll(event.target);
386
+ };
387
+ const onWindowScroll = () => {
388
+ this.handleScroll(this.contextWindow);
389
+ };
390
+ this.contextWindow.addEventListener('scroll', onElementScroll, { capture: true });
391
+ this.contextWindow.addEventListener('scroll', onWindowScroll);
392
+ this.removeScrollListeners = () => {
393
+ this.contextWindow.removeEventListener('scroll', onElementScroll, {
394
+ capture: true
395
+ });
396
+ this.contextWindow.removeEventListener('scroll', onWindowScroll);
397
+ };
398
+ }
399
+ handleScroll(target) {
400
+ const initial = this.scrollPositions.get(target);
401
+ if (!initial)
402
+ return;
403
+ const isWindow = target === this.contextWindow;
404
+ const current = isWindow
405
+ ? { x: this.contextWindow.scrollX, y: this.contextWindow.scrollY }
406
+ : {
407
+ x: target.scrollLeft,
408
+ y: target.scrollTop
409
+ };
410
+ const delta = { x: current.x - initial.x, y: current.y - initial.y };
411
+ if (delta.x === 0 && delta.y === 0)
412
+ return;
413
+ if (isWindow) {
414
+ if (this.lastMovePoint) {
415
+ this.lastMovePoint.x += delta.x;
416
+ this.lastMovePoint.y += delta.y;
417
+ }
418
+ }
419
+ else if (this.history.length > 0) {
420
+ this.history[0].x -= delta.x;
421
+ this.history[0].y -= delta.y;
422
+ }
423
+ this.scrollPositions.set(target, current);
424
+ frame.update(this.updatePoint, true);
425
+ }
426
+ }
@@ -9,6 +9,10 @@ import { type AugmentedMotionValue } from './augmentMotionValue.svelte.js';
9
9
  * `velocity`, `restDelta`, `restSpeed`) plus `skipInitialAnimation` for
10
10
  * scroll-restoration scenarios.
11
11
  *
12
+ * `useSpring` is a thin wrapper over {@link useFollowValue} that hard-codes
13
+ * `type: 'spring'`. For other transition types (tween, inertia, keyframes)
14
+ * use `useFollowValue` directly.
15
+ *
12
16
  * @see https://motion.dev/docs/react-use-spring
13
17
  */
14
18
  export type UseSpringOptions = SpringOptions & Pick<FollowValueOptions, 'skipInitialAnimation'>;
@@ -22,41 +26,36 @@ export type UseSpringOptions = SpringOptions & Pick<FollowValueOptions, 'skipIni
22
26
  * - `current` — a Svelte-5 reactive read backed by `$state`. Use in templates
23
27
  * and `$derived` / `$effect` to track the spring value without subscribing.
24
28
  * - `subscribe` — Svelte readable store contract. Calls the run function once
25
- * synchronously with the current value, then on every change. Lets the
26
- * spring be used with `$spring` template syntax, `get(spring)`, and as a
27
- * dependency in `useTransform`'s function form.
29
+ * synchronously with the current value, then on every change.
28
30
  */
29
31
  export type SpringMotionValue<T extends number | string> = AugmentedMotionValue<T>;
30
32
  /**
31
33
  * Creates a spring-animated `MotionValue`.
32
34
  *
33
35
  * Set a target with `.set(v)` to animate to it using spring physics, or
34
- * `.jump(v)` to skip the animation. Pass another `MotionValue` (or, for
35
- * backwards compatibility, a Svelte readable store like the ones from
36
- * `useScroll` / `useTime`) as `source` and the spring will animate towards
37
- * whatever that source emits.
38
- *
39
- * Returned object is a real motion-dom `MotionValue` — composes with
40
- * `animate()`, `useTransform`, `useVelocity`, and motion-dom's animation
41
- * engine. On top, it exposes:
36
+ * `.jump(v)` to skip the animation. Pass another `MotionValue` (or a Svelte
37
+ * readable store from `useScroll` / `useTime`) as `source` and the spring
38
+ * will animate toward whatever that source emits.
42
39
  *
43
- * - `.current` Svelte-5 reactive read for templates and `$derived` /
44
- * `$effect`.
45
- * - `.subscribe(run)` Svelte readable store contract so `$spring` template
46
- * syntax and `useTransform(() => …, [spring])` keep working during the
47
- * Tier 2 migration window.
40
+ * Returned object is a real motion-dom `MotionValue` augmented with a
41
+ * `$state`-backed `.current` getter and a Svelte readable `.subscribe` shim.
42
+ * Composes with `useTransform`, `useVelocity`, `animate()`, and the rest of
43
+ * the motion-value surface.
48
44
  *
49
- * Lifecycle: must be called during component initialization. Cleanup is
50
- * registered via `$effect`; the spring stops animating and unsubscribes from
51
- * its source when the surrounding component / effect tears down. Call
52
- * `.destroy()` to clean up early.
45
+ * Lifecycle: must be called during component initialization. The follow
46
+ * animation, source bridge (if any), and motion value teardown are bound to
47
+ * the surrounding `$effect` scope.
53
48
  *
54
49
  * SSR-safe: returns a static `MotionValue` with no animation on the server.
55
50
  *
56
- * @template T
57
- * @param {number|string|MotionValue<number>|MotionValue<string>|Readable<number|string>} source Initial value or a source to follow.
58
- * @param {UseSpringOptions} [options] Spring + follow configuration.
59
- * @returns {SpringMotionValue<T>} A `MotionValue` with `.current` and `.subscribe`.
51
+ * Implementation: thin wrapper over {@link useFollowValue} that hard-codes
52
+ * `type: 'spring'`. For tween / inertia / keyframes follows, call
53
+ * `useFollowValue` directly.
54
+ *
55
+ * @template T The value type — `number` or `string` (unit strings preserved).
56
+ * @param source Initial value, a `MotionValue` to follow, or a Svelte readable.
57
+ * @param options Spring + follow configuration.
58
+ * @returns A `MotionValue<T>` with `.current` and `.subscribe`.
60
59
  *
61
60
  * @example
62
61
  * ```svelte
@@ -67,7 +66,7 @@ export type SpringMotionValue<T extends number | string> = AugmentedMotionValue<
67
66
  * </script>
68
67
  *
69
68
  * <button onclick={() => x.set(100)}>Animate</button>
70
- * <div>{x.current}</div>
69
+ * <div style="transform: translateX({x.current}px)">{x.current}</div>
71
70
  * ```
72
71
  *
73
72
  * @see https://motion.dev/docs/react-use-spring
@@ -1,54 +1,10 @@
1
- import { attachFollow, isMotionValue, motionValue } from 'motion-dom';
1
+ import {} from 'motion-dom';
2
2
  import {} from 'svelte/store';
3
- import { augmentMotionValue, isSvelteReadable, sampleSource } from './augmentMotionValue.svelte.js';
3
+ import {} from './augmentMotionValue.svelte.js';
4
+ import { useFollowValue } from './followValue.svelte.js';
4
5
  export function useSpring(source, options = {}) {
5
- // SSR: return a static MotionValue with no animation. Reads return the
6
- // best-effort initial value; .set / .jump become no-ops to avoid drifting
7
- // away from the server-rendered snapshot.
8
- if (typeof window === 'undefined') {
9
- const initial = sampleSource(source);
10
- const ssrValue = motionValue(initial);
11
- ssrValue.set = () => undefined;
12
- ssrValue.jump = () => undefined;
13
- return augmentMotionValue(ssrValue);
14
- }
15
- // Resolve initial + follow source.
16
- let followSource;
17
- let cleanupReadableBridge;
18
- let svelteBridge;
19
- if (isMotionValue(source)) {
20
- followSource = source;
21
- }
22
- else if (isSvelteReadable(source)) {
23
- // Bridge a Svelte readable into a MotionValue so attachFollow can
24
- // track it. Synchronous initial sample comes from svelte/store's get().
25
- const initialFromReadable = sampleSource(source);
26
- svelteBridge = motionValue(initialFromReadable);
27
- cleanupReadableBridge = source.subscribe((v) => {
28
- // The Svelte readable contract calls the subscriber synchronously
29
- // with the current value on subscribe. Skip if it equals the
30
- // already-seeded bridge value so attachFollow doesn't fire a
31
- // spring on the initial emit. Subsequent emits go through set()
32
- // and trigger animation.
33
- if (svelteBridge.get() === v)
34
- return;
35
- svelteBridge.set(v);
36
- });
37
- followSource = svelteBridge;
38
- }
39
- else {
40
- followSource = source;
41
- }
42
- const initial = isMotionValue(followSource) ? followSource.get() : followSource;
43
- const value = motionValue(initial);
44
- const stopFollow = attachFollow(value, followSource, { type: 'spring', ...options });
45
- // Side-cleanup for our augmentations. Single-shot guard lives in the
46
- // augmented `value.destroy` (the only caller), so no flag here.
47
- const dispose = () => {
48
- stopFollow?.();
49
- cleanupReadableBridge?.();
50
- svelteBridge?.destroy();
51
- };
52
- $effect(() => () => value.destroy());
53
- return augmentMotionValue(value, dispose);
6
+ // Cast through `unknown` because TypeScript can't narrow the multi-form
7
+ // `source` against `useFollowValue`'s overload set; runtime behavior is
8
+ // identical `useFollowValue` dispatches on the same shapes.
9
+ return useFollowValue(source, { type: 'spring', ...options });
54
10
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanspeak/svelte-motion",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Framer Motion for Svelte 5. Declarative motion.<tag> components with AnimatePresence exit animations, gestures (hover, tap, drag, focus, in-view), variants, FLIP layout animations, shared-layout transitions, spring physics, and scroll-linked motion values. The drop-in Framer Motion alternative for Svelte and SvelteKit.",
5
5
  "keywords": [
6
6
  "svelte",
@@ -104,7 +104,7 @@
104
104
  "@eslint/js": "^10.0.1",
105
105
  "@playwright/test": "^1.60.0",
106
106
  "@sveltejs/adapter-auto": "^7.0.1",
107
- "@sveltejs/kit": "^2.60.1",
107
+ "@sveltejs/kit": "^2.61.1",
108
108
  "@sveltejs/package": "^2.5.7",
109
109
  "@sveltejs/vite-plugin-svelte": "^7.1.2",
110
110
  "@tailwindcss/aspect-ratio": "^0.4.2",
@@ -131,7 +131,7 @@
131
131
  "prettier": "^3.8.3",
132
132
  "prettier-plugin-organize-imports": "^4.3.0",
133
133
  "prettier-plugin-sort-json": "^4.2.0",
134
- "prettier-plugin-svelte": "^3.5.2",
134
+ "prettier-plugin-svelte": "^4.0.1",
135
135
  "prettier-plugin-tailwindcss": "^0.8.0",
136
136
  "publint": "^0.3.21",
137
137
  "runed": "0.37.1",
@@ -144,7 +144,7 @@
144
144
  "tailwindcss-animate": "^1.0.7",
145
145
  "tsx": "^4.22.3",
146
146
  "typescript": "^6.0.3",
147
- "typescript-eslint": "^8.59.4",
147
+ "typescript-eslint": "^8.60.0",
148
148
  "vite": "^8.0.14",
149
149
  "vite-tsconfig-paths": "^6.1.1",
150
150
  "vitest": "^4.1.7"