@humanspeak/svelte-motion 0.3.4 → 0.3.6

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,124 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte'
3
+ import {
4
+ getAnimatePresenceContext,
5
+ getPresenceDepth,
6
+ setPresenceChildContext,
7
+ setPresenceDepth
8
+ } from '../utils/presence'
9
+
10
+ /**
11
+ * Holds its `children` snippet rendered until the consumer signals the
12
+ * exit is complete via `safeToRemove()`. Lets a child run its own (non-
13
+ * `motion.*`) exit animation — fade via CSS transition, canvas effect,
14
+ * GSAP, etc — while still participating in `AnimatePresence`'s
15
+ * `onExitComplete` accounting and `mode='wait'` enter blocking.
16
+ *
17
+ * Children read `useIsPresent()` or `usePresence()` to observe the
18
+ * exit-phase flip.
19
+ *
20
+ * @prop present Bind to the same boolean that conditionally rendered this
21
+ * wrapper. When it flips to `false`, the wrapper holds children
22
+ * mounted with `isPresent = false` until `safeToRemove` fires.
23
+ * @prop children Snippet rendered while present or held.
24
+ */
25
+ const { present = true, children } = $props<{
26
+ present?: boolean
27
+ children?: Snippet
28
+ }>()
29
+
30
+ const animatePresence = getAnimatePresenceContext()
31
+ const presenceDepth = getPresenceDepth()
32
+
33
+ // Descendants of PresenceChild are no longer at depth 0, so any motion.*
34
+ // children inside don't trip the "direct children of AnimatePresence need
35
+ // a key" check in _MotionContainer.
36
+ if (presenceDepth !== undefined) {
37
+ setPresenceDepth(presenceDepth + 1)
38
+ }
39
+
40
+ // Three-phase exit lifecycle:
41
+ // 'idle' — no exit in progress; render iff `present`.
42
+ // 'holding' — `present` flipped false; we're still rendering with
43
+ // isPresent=false, waiting for the consumer to call
44
+ // safeToRemove.
45
+ // 'completed' — consumer signaled completion. Stop rendering. Returning
46
+ // to 'idle' only happens if `present` flips back to true.
47
+ type ExitPhase = 'idle' | 'holding' | 'completed'
48
+ let phase = $state<ExitPhase>('idle')
49
+ // Track the prior `present` value so we only start an exit on a true→false
50
+ // transition, not when mounted with `present=false` (a no-op steady state).
51
+ // Using a closure variable inside $effect.pre below — see initial value
52
+ // capture there.
53
+ let prevPresent: boolean | undefined = undefined
54
+
55
+ // Each exit start mints a fresh `safeToRemove` closure bound to the cycle
56
+ // it was minted for. Re-entry / completion invalidates older closures so a
57
+ // captured-then-late-fired callback (setTimeout, external lib, stray
58
+ // listener after cleanup) from cycle A cannot complete cycle B.
59
+ let currentSafeToRemove: () => void = noopSafeToRemove
60
+
61
+ function noopSafeToRemove() {}
62
+
63
+ function mintSafeToRemove(): () => void {
64
+ // Closure compares against its own identity (`self`) to detect
65
+ // whether it's still the active cycle's callback. After re-entry or
66
+ // completion, `currentSafeToRemove` no longer points at `self`, so
67
+ // this branch no-ops — a stale capture cannot complete a later exit.
68
+ const self: () => void = () => {
69
+ if (currentSafeToRemove !== self || phase !== 'holding') return
70
+ phase = 'completed'
71
+ currentSafeToRemove = noopSafeToRemove
72
+ animatePresence?.notifyExitComplete()
73
+ }
74
+ return self
75
+ }
76
+
77
+ const isPresent = $derived(present && phase === 'idle')
78
+
79
+ setPresenceChildContext({
80
+ get isPresent() {
81
+ return isPresent
82
+ },
83
+ get safeToRemove() {
84
+ return currentSafeToRemove
85
+ }
86
+ })
87
+
88
+ // $effect.pre runs before DOM updates so the phase transition lands in
89
+ // the same render pass as the `present` prop flip — otherwise the {#if}
90
+ // below re-evaluates with stale phase and unmounts/remounts the children,
91
+ // which (a) breaks any CSS transition the consumer relies on for exit and
92
+ // (b) makes `bind:this` references go stale.
93
+ $effect.pre(() => {
94
+ // Read `present` reactively first; assignments to module locals
95
+ // happen inside the branches below.
96
+ const current = present
97
+ // First run: just record the steady state. Don't fire any exit/enter
98
+ // signal — there's no transition to react to yet.
99
+ if (prevPresent === undefined) {
100
+ prevPresent = current
101
+ return
102
+ }
103
+ if (prevPresent && !current && phase === 'idle') {
104
+ phase = 'holding'
105
+ currentSafeToRemove = mintSafeToRemove()
106
+ animatePresence?.notifyExitStart()
107
+ } else if (!prevPresent && current && phase === 'holding') {
108
+ // Re-entry mid-hold cancels the exit accounting. Replacing the
109
+ // slot invalidates the cycle's `safeToRemove` for any consumer
110
+ // that captured it.
111
+ phase = 'idle'
112
+ currentSafeToRemove = noopSafeToRemove
113
+ animatePresence?.notifyExitComplete()
114
+ } else if (!prevPresent && current && phase === 'completed') {
115
+ // Re-mounted after a previous exit fully completed.
116
+ phase = 'idle'
117
+ }
118
+ prevPresent = current
119
+ })
120
+ </script>
121
+
122
+ {#if present || phase === 'holding'}
123
+ {@render children?.()}
124
+ {/if}
@@ -0,0 +1,8 @@
1
+ import type { Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ present?: boolean;
4
+ children?: Snippet;
5
+ };
6
+ declare const PresenceChild: import("svelte").Component<$$ComponentProps, {}, "">;
7
+ type PresenceChild = ReturnType<typeof PresenceChild>;
8
+ export default PresenceChild;
@@ -42,6 +42,7 @@
42
42
  import { isNativelyFocusable } from '../utils/a11y'
43
43
  import {
44
44
  getAnimatePresenceContext,
45
+ getPresenceChildContext,
45
46
  getPresenceDepth,
46
47
  setPresenceDepth
47
48
  } from '../utils/presence'
@@ -127,6 +128,12 @@
127
128
 
128
129
  // Get presence context to check if we're inside AnimatePresence
129
130
  const context = getAnimatePresenceContext()
131
+ // Inside a <PresenceChild>, the wrapper drives the exit. Skip the
132
+ // clone-based exit registration on this element so we don't double-fire
133
+ // (custom exit, then clone of a node the wrapper already let go).
134
+ // Enter-side coordination (shouldAnimateEnter, mode='wait' blocking)
135
+ // remains active so the element still slots into the outer presence flow.
136
+ const inPresenceChild = !!getPresenceChildContext()
130
137
 
131
138
  // Get layoutId registry (provided by AnimatePresence or a parent LayoutGroup)
132
139
  const layoutIdRegistry = getLayoutIdRegistry()
@@ -168,9 +175,8 @@
168
175
  )
169
176
 
170
177
  // Register onDestroy at component level (guaranteed to work in Svelte 5)
171
- // usePresence() cannot be called inside $effect because it uses getContext() and onDestroy(),
172
- // which must be called during component initialization.
173
- if (context) {
178
+ // — getContext()/onDestroy() must run during component initialization.
179
+ if (context && !inPresenceChild) {
174
180
  onDestroy(() => {
175
181
  pwLog('[presence] onDestroy triggered', { key: presenceKey })
176
182
  context.unregisterChild(presenceKey)
@@ -181,8 +187,9 @@
181
187
  // from the correct visual state. Without this, interrupting an enter animation
182
188
  // causes the exit to snap (the element is disconnected before onDestroy, so
183
189
  // getAnimations()/commitStyles() can't work at clone time).
190
+ // Skipped inside <PresenceChild>: the wrapper drives exit, no clone path.
184
191
  $effect(() => {
185
- if (!(element && context)) return
192
+ if (!(element && context) || inPresenceChild) return
186
193
  let rafId: number
187
194
  const capture = () => {
188
195
  if (element && element.isConnected && element.getAnimations().length > 0) {
@@ -227,7 +234,7 @@
227
234
 
228
235
  // Reactively update registration when element/exit/transition props change
229
236
  $effect(() => {
230
- if (element && context && resolvedExit) {
237
+ if (element && context && !inPresenceChild && resolvedExit) {
231
238
  const filteredExit = filterReducedMotionKeyframes(
232
239
  resolvedExit as Record<string, unknown>,
233
240
  reducedMotion
@@ -241,9 +248,10 @@
241
248
  }
242
249
  })
243
250
 
244
- // Update presence context with current state when element is ready and has size
251
+ // Update presence context with current state when element is ready and has size.
252
+ // Skipped inside <PresenceChild> — the rect/style snapshot only feeds the clone path.
245
253
  $effect(() => {
246
- if (!(context && element && isLoaded === 'ready')) return
254
+ if (!(context && element && isLoaded === 'ready') || inPresenceChild) return
247
255
 
248
256
  let lastWidth = 0
249
257
  let lastHeight = 0
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import AnimatePresence from './components/AnimatePresence.svelte';
2
2
  import MotionConfig from './components/MotionConfig.svelte';
3
+ import PresenceChild from './components/PresenceChild.svelte';
3
4
  export { motion } from './motion';
4
5
  export { animate, delay, hover, inView, press, resize, scroll, stagger, transform } from 'motion';
5
6
  export { anticipate, backIn, backInOut, backOut, circIn, circInOut, circOut, cubicBezier, easeIn, easeInOut, easeOut } from 'motion';
@@ -21,6 +22,8 @@ export { useReducedMotion } from './utils/reducedMotion';
21
22
  export { useReducedMotionConfig } from './utils/reducedMotionConfig';
22
23
  export { useScroll } from './utils/scroll';
23
24
  export { useSpring } from './utils/spring';
25
+ export { useIsPresent, usePresence } from './utils/usePresence';
26
+ export type { UsePresenceState } from './utils/usePresence';
24
27
  export { useVelocity } from './utils/velocity';
25
28
  /**
26
29
  * @deprecated Use `styleString` instead for reactive styles with automatic unit handling.
@@ -29,7 +32,7 @@ export { stringifyStyleObject } from './utils/styleObject';
29
32
  export { styleString } from './utils/styleObject.svelte';
30
33
  export { useTime } from './utils/time';
31
34
  export { useTransform } from './utils/transform';
32
- export { AnimatePresence, MotionConfig };
35
+ export { AnimatePresence, MotionConfig, PresenceChild };
33
36
  export { default as MotionA } from './html/A.svelte';
34
37
  export { default as MotionAbbr } from './html/Abbr.svelte';
35
38
  export { default as MotionAddress } from './html/Address.svelte';
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import AnimatePresence from './components/AnimatePresence.svelte';
2
2
  import MotionConfig from './components/MotionConfig.svelte';
3
+ import PresenceChild from './components/PresenceChild.svelte';
3
4
  export { motion } from './motion';
4
5
  // Re-export core animation functions from motion
5
6
  export { animate, delay, hover, inView, press, resize, scroll, stagger, transform } from 'motion';
@@ -19,6 +20,7 @@ export { useReducedMotion } from './utils/reducedMotion';
19
20
  export { useReducedMotionConfig } from './utils/reducedMotionConfig';
20
21
  export { useScroll } from './utils/scroll';
21
22
  export { useSpring } from './utils/spring';
23
+ export { useIsPresent, usePresence } from './utils/usePresence';
22
24
  export { useVelocity } from './utils/velocity';
23
25
  /**
24
26
  * @deprecated Use `styleString` instead for reactive styles with automatic unit handling.
@@ -27,7 +29,7 @@ export { stringifyStyleObject } from './utils/styleObject';
27
29
  export { styleString } from './utils/styleObject.svelte';
28
30
  export { useTime } from './utils/time';
29
31
  export { useTransform } from './utils/transform';
30
- export { AnimatePresence, MotionConfig };
32
+ export { AnimatePresence, MotionConfig, PresenceChild };
31
33
  // Named component exports — tree-shakeable alternative to the `motion` object
32
34
  export { default as MotionA } from './html/A.svelte';
33
35
  export { default as MotionAbbr } from './html/Abbr.svelte';
@@ -1,4 +1,4 @@
1
- import { pwLog, pwWarn } from './log';
1
+ import { isPlaywrightEnv, pwLog, pwWarn } from './log';
2
2
  /**
3
3
  * Drag utilities
4
4
  *
@@ -16,7 +16,7 @@ import { pwLog, pwWarn } from './log';
16
16
  * - For nested drags, set `propagation` as needed to avoid parent-child contention.
17
17
  */
18
18
  import { isDomElement } from './dom';
19
- import { applyConstraints as applyFloatConstraints } from './dragMath';
19
+ import { applyConstraints as applyFloatConstraints, parseMatrixTranslate } from './dragMath';
20
20
  import { deriveBoundaryPhysics } from './dragParams';
21
21
  import { computeHoverBaseline, splitHoverDefinition } from './hover';
22
22
  import { createInertiaToBoundary } from './inertia';
@@ -87,6 +87,53 @@ export const applyElastic = (value, min, max, elastic) => {
87
87
  };
88
88
  /** Prefer high-resolution time in browser; fall back for SSR/tests. */
89
89
  const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
90
+ /** Sample windows for release-velocity inference (matches motion-dom values). */
91
+ const MAX_VELOCITY_DELTA_MS = 30;
92
+ const MIN_VELOCITY_INTERVAL_MS = 5;
93
+ /**
94
+ * Compute the release velocity for momentum from a pointer-history window.
95
+ *
96
+ * Mirrors motion-dom: walks back from the newest sample, including only
97
+ * samples within `MAX_VELOCITY_DELTA_MS` (30 ms) of newest, then divides
98
+ * the displacement by the elapsed time. Returns 0 if the newest sample is
99
+ * stale, the window has fewer than two samples, or the oldest-newest span
100
+ * is shorter than `MIN_VELOCITY_INTERVAL_MS` (5 ms — sub-frame).
101
+ *
102
+ * @param history Recent pointer samples ordered oldest → newest. Each
103
+ * sample is `{ x, y, t }` where `t` is `performance.now()` ms.
104
+ * @param nowMs Current `performance.now()` ms — used to discard a stale
105
+ * newest sample (finger lifted after a pause).
106
+ * @returns Inferred release velocity in pixels per second on each axis.
107
+ * @example
108
+ * const v = computeReleaseVelocity(
109
+ * [{ x: 0, y: 0, t: 1000 }, { x: 20, y: 0, t: 1020 }],
110
+ * 1020
111
+ * )
112
+ * // v ≈ { x: 1000, y: 0 } — 20 px over 20 ms → 1000 px/s
113
+ */
114
+ const computeReleaseVelocity = (history, nowMs) => {
115
+ if (history.length < 2)
116
+ return { x: 0, y: 0 };
117
+ const newest = history[history.length - 1];
118
+ if (nowMs - newest.t > MAX_VELOCITY_DELTA_MS)
119
+ return { x: 0, y: 0 };
120
+ let oldestIdx = history.length - 1;
121
+ for (let i = history.length - 2; i >= 0; i--) {
122
+ if (newest.t - history[i].t > MAX_VELOCITY_DELTA_MS)
123
+ break;
124
+ oldestIdx = i;
125
+ }
126
+ if (oldestIdx === history.length - 1)
127
+ return { x: 0, y: 0 };
128
+ const oldest = history[oldestIdx];
129
+ const dtMs = newest.t - oldest.t;
130
+ if (dtMs < MIN_VELOCITY_INTERVAL_MS)
131
+ return { x: 0, y: 0 };
132
+ return {
133
+ x: ((newest.x - oldest.x) / dtMs) * 1000,
134
+ y: ((newest.y - oldest.y) / dtMs) * 1000
135
+ };
136
+ };
90
137
  /**
91
138
  * Attach a drag gesture to an element.
92
139
  *
@@ -184,25 +231,21 @@ export const attachDrag = (el, opts) => {
184
231
  applied.x = x;
185
232
  if ('y' in payload)
186
233
  applied.y = y;
187
- // Verify it's actually applied; if not, retry once (Playwright visibility only)
188
- const cs = getComputedStyle(el);
189
- let actualTransform = cs.transform;
190
- if (actualTransform === 'none' || !actualTransform.includes('matrix')) {
191
- pwWarn('⚠️ setXY transform missing; retrying write', {
192
- x,
193
- y,
194
- transform: actualTransform
195
- });
196
- animate(el, ('x' in payload || 'y' in payload ? payload : { x, y }), {
197
- duration: 0
198
- });
199
- actualTransform = getComputedStyle(el).transform;
234
+ // Playwright-only sanity check: confirm the transform actually
235
+ // landed on the element and retry once if not. Forces a style
236
+ // recalc via getComputedStyle, so we gate it behind the same
237
+ // playwright env flag pwLog uses so it never fires in prod.
238
+ if (isPlaywrightEnv()) {
239
+ let actualTransform = getComputedStyle(el).transform;
200
240
  if (actualTransform === 'none' || !actualTransform.includes('matrix')) {
201
- pwWarn('⚠️ setXY second attempt still missing transform', {
202
- x,
203
- y,
204
- transform: actualTransform
205
- });
241
+ pwWarn('⚠️ setXY transform missing; retrying write', { x, y });
242
+ animate(el, ('x' in payload || 'y' in payload
243
+ ? payload
244
+ : { x, y }), { duration: 0 });
245
+ actualTransform = getComputedStyle(el).transform;
246
+ if (actualTransform === 'none' || !actualTransform.includes('matrix')) {
247
+ pwWarn('⚠️ setXY second attempt still missing transform', { x, y });
248
+ }
206
249
  }
207
250
  }
208
251
  };
@@ -404,22 +447,28 @@ export const attachDrag = (el, opts) => {
404
447
  });
405
448
  if (!dragging)
406
449
  return;
407
- finishDrag(e);
450
+ // Pointer was preempted (gesture-nav, palm rejection, scroll
451
+ // takeover). User did not release intentionally — skip the
452
+ // inertia/momentum path and force a no-momentum settle so the
453
+ // card clamps back into constraints without flinging.
454
+ finishDrag(e, true);
408
455
  };
409
456
  /**
410
457
  * Finish a drag:
411
458
  * - If momentum is enabled, decay towards a clamped target with exponential easing
412
459
  * - Otherwise, animate back to a clamped position (or origin), then sync `applied`
413
460
  */
414
- const finishDrag = (e) => {
461
+ const finishDrag = (e, cancelled = false) => {
415
462
  dragging = false;
463
+ velocity = computeReleaseVelocity(history, now());
416
464
  pwLog('[drag] finish', {
417
465
  el: EL_ID,
418
466
  lastPoint,
419
467
  startPoint,
420
468
  origin,
421
469
  applied,
422
- momentum
470
+ momentum,
471
+ velocity
423
472
  });
424
473
  try {
425
474
  if ('releasePointerCapture' in el && typeof e.pointerId === 'number')
@@ -434,8 +483,10 @@ export const attachDrag = (el, opts) => {
434
483
  window.removeEventListener('pointermove', onPointerMove);
435
484
  window.removeEventListener('pointerup', onPointerUp);
436
485
  window.removeEventListener('pointercancel', onPointerCancel);
437
- // Momentum/inertia with boundary handoff: inertia until crossing, then spring to boundary
438
- if (momentum) {
486
+ // Momentum/inertia with boundary handoff: inertia until crossing, then spring to boundary.
487
+ // Pointer-cancel forces a no-momentum settle (clamp into constraints, no fling) since the
488
+ // gesture was preempted rather than intentionally released.
489
+ if (momentum && !cancelled) {
439
490
  pwLog('🚀 STARTING MOMENTUM', {
440
491
  velocityX: velocity.x,
441
492
  velocityY: velocity.y,
@@ -454,6 +505,18 @@ export const attachDrag = (el, opts) => {
454
505
  ...(applyX ? { x: 0 } : {}),
455
506
  ...(applyY ? { y: 0 } : {})
456
507
  }, (mergedTransition ?? {}));
508
+ // Cancel hook so re-grab / controls.stop() interrupts the snap.
509
+ stopInertia = () => {
510
+ pwLog('❌ snapToOrigin cancelled');
511
+ controls.stop?.();
512
+ // Sync applied to wherever the snap reached so the next drag origin is correct.
513
+ const { tx, ty } = parseMatrixTranslate(getComputedStyle(el).transform);
514
+ if (applyX)
515
+ applied.x = tx;
516
+ if (applyY)
517
+ applied.y = ty;
518
+ stopInertia = null;
519
+ };
457
520
  Promise.resolve(controls.finished)
458
521
  .then(() => {
459
522
  // Sync internal applied transform so next drag uses the correct origin
@@ -465,26 +528,31 @@ export const attachDrag = (el, opts) => {
465
528
  el: EL_ID,
466
529
  applied
467
530
  });
531
+ if (stopInertia)
532
+ stopInertia = null;
468
533
  })
469
534
  .catch(() => { })
470
535
  .finally(() => opts.callbacks?.onTransitionEnd?.());
471
536
  return;
472
537
  }
473
- // If no constraints, disable boundary springs by setting finite but very wide bounds
538
+ // Boundary min/max anchor to `constraintsBase` (the absolute
539
+ // pixel-constraint origin) so the inertia handoff snaps to the
540
+ // same edge pointermove's elastic clamping uses — not to a
541
+ // per-drag-shifted `origin` that would drift across drags.
474
542
  const noConstraints = !constraints;
475
543
  const huge = 1e6;
476
544
  const minX = noConstraints
477
545
  ? applied.x - huge
478
- : origin.x + (constraints?.left ?? -Infinity);
546
+ : constraintsBase.x + (constraints?.left ?? -Infinity);
479
547
  const maxX = noConstraints
480
548
  ? applied.x + huge
481
- : origin.x + (constraints?.right ?? Infinity);
549
+ : constraintsBase.x + (constraints?.right ?? Infinity);
482
550
  const minY = noConstraints
483
551
  ? applied.y - huge
484
- : origin.y + (constraints?.top ?? -Infinity);
552
+ : constraintsBase.y + (constraints?.top ?? -Infinity);
485
553
  const maxY = noConstraints
486
554
  ? applied.y + huge
487
- : origin.y + (constraints?.bottom ?? Infinity);
555
+ : constraintsBase.y + (constraints?.bottom ?? Infinity);
488
556
  const { timeConstantMs, restDelta, restSpeed, bounceStiffness, bounceDamping } = deriveBoundaryPhysics(elastic, opts.transition);
489
557
  pwLog('⚙️ boundary-physics', {
490
558
  timeConstantMs,
@@ -500,6 +568,10 @@ export const attachDrag = (el, opts) => {
500
568
  // Respect direction lock on release: only animate the locked axis
501
569
  const applyX = (axis === true || axis === 'x') && lockAxis !== 'y';
502
570
  const applyY = (axis === true || axis === 'y') && lockAxis !== 'x';
571
+ // Element-ref constraints can resize / reflow during inertia.
572
+ // Pixel constraints never change once set. We re-resolve only
573
+ // for element-ref each frame in the rAF loop below.
574
+ const isElementRefConstraint = isDomElement(opts.constraints);
503
575
  const stepX = applyX
504
576
  ? createInertiaToBoundary({ value: applied.x, velocity: velocity.x }, { min: minX, max: maxX }, { timeConstantMs, restDelta, restSpeed, bounceStiffness, bounceDamping })
505
577
  : null;
@@ -519,9 +591,27 @@ export const attachDrag = (el, opts) => {
519
591
  // Use the precomputed step functions (built once per release)
520
592
  const rx = stepX ? stepX(t) : { value: applied.x, done: true };
521
593
  const ry = stepY ? stepY(t) : { value: applied.y, done: true };
522
- // Preserve non-animated axis exactly; don't write y during x-only drags, or vice versa
523
- const nextX = rx.value;
524
- const nextY = (axis === true || axis === 'y') && lockAxis !== 'x' ? ry.value : applied.y;
594
+ // Element-ref constraints may have resized / reflowed since
595
+ // the steppers were built. Re-resolve and clamp the output
596
+ // so the card never lands outside the now-current container
597
+ // even if its boundary moved mid-spring. Pixel constraints
598
+ // don't move so we skip the work.
599
+ let nextX = rx.value;
600
+ let nextY = (axis === true || axis === 'y') && lockAxis !== 'x' ? ry.value : applied.y;
601
+ if (isElementRefConstraint) {
602
+ const freshConstraints = resolveConstraints(el, opts.constraints);
603
+ if (freshConstraints) {
604
+ constraintsBase = { x: applied.x, y: applied.y };
605
+ const freshMinX = constraintsBase.x + (freshConstraints.left ?? -Infinity);
606
+ const freshMaxX = constraintsBase.x + (freshConstraints.right ?? Infinity);
607
+ const freshMinY = constraintsBase.y + (freshConstraints.top ?? -Infinity);
608
+ const freshMaxY = constraintsBase.y + (freshConstraints.bottom ?? Infinity);
609
+ if (applyX)
610
+ nextX = Math.max(freshMinX, Math.min(freshMaxX, nextX));
611
+ if (applyY)
612
+ nextY = Math.max(freshMinY, Math.min(freshMaxY, nextY));
613
+ }
614
+ }
525
615
  setXY(nextX, nextY);
526
616
  if (frameCount <= 3 || frameCount % 10 === 0) {
527
617
  pwLog(`🔄 FRAME ${frameCount}`, {
@@ -537,15 +627,19 @@ export const attachDrag = (el, opts) => {
537
627
  if ((rx.done || !stepX) && (ry.done || !stepY)) {
538
628
  pwLog('✅ REST REACHED', {
539
629
  frameCount,
540
- finalX: rx.value,
541
- finalY: ry.value,
630
+ finalX: nextX,
631
+ finalY: nextY,
542
632
  timeConstantMs,
543
633
  restDelta,
544
634
  restSpeed
545
635
  });
546
- // Ensure applied is synced to final values (setXY should have done this, but be explicit)
547
- const finalX = stepX ? rx.value : applied.x;
548
- const finalY = stepY ? ry.value : applied.y;
636
+ // Sync `applied` from the post-clamp frame values
637
+ // (nextX/nextY), not the raw stepper output. When
638
+ // element-ref constraints clamped this frame, raw
639
+ // rx.value sits outside the visible bounds and would
640
+ // desync the next-drag origin from the rendered transform.
641
+ const finalX = stepX ? nextX : applied.x;
642
+ const finalY = stepY ? nextY : applied.y;
549
643
  if (axis === true || axis === 'x')
550
644
  applied.x = finalX;
551
645
  if (axis === true || axis === 'y')
@@ -560,14 +654,12 @@ export const attachDrag = (el, opts) => {
560
654
  stopInertia = () => {
561
655
  pwLog('❌ MOMENTUM CANCELLED');
562
656
  running = false;
563
- // Sync applied to the current visual position so the next drag starts correctly
564
- // Use the last computed values from the steppers if available
565
- const finalX = stepX ? stepX(now() - startTs).value : applied.x;
566
- const finalY = stepY ? stepY(now() - startTs).value : applied.y;
567
- if (axis === true || axis === 'x')
568
- applied.x = finalX;
569
- if (axis === true || axis === 'y')
570
- applied.y = finalY;
657
+ // `applied` is already in sync with the last frame rendered
658
+ // by the rAF loop (setXY updates it on every frame). We
659
+ // intentionally do NOT call stepX/stepY again here
660
+ // they're stateful (mutate lastT/springX/springV) and an
661
+ // extra call advances them past the visible state, leaving
662
+ // applied slightly out of sync with what the user sees.
571
663
  pwLog('[drag] inertia cancelled → sync applied', {
572
664
  el: EL_ID,
573
665
  applied: { x: applied.x, y: applied.y }
@@ -624,6 +716,17 @@ export const attachDrag = (el, opts) => {
624
716
  const settleTransition = elastic === 0 ? { duration: 0 } : (mergedTransition ?? {});
625
717
  // Animate with the merged transition so the settle feels consistent with other motion
626
718
  const controls = animate(el, { ...(applyX ? { x } : {}), ...(applyY ? { y } : {}) }, settleTransition);
719
+ // Cancel hook so re-grab interrupts the settle animation cleanly.
720
+ stopInertia = () => {
721
+ pwLog('❌ settle (no momentum) cancelled');
722
+ controls.stop?.();
723
+ const { tx, ty } = parseMatrixTranslate(getComputedStyle(el).transform);
724
+ if (applyX)
725
+ applied.x = tx;
726
+ if (applyY)
727
+ applied.y = ty;
728
+ stopInertia = null;
729
+ };
627
730
  // Fire transition end once settled
628
731
  Promise.resolve(controls.finished)
629
732
  .then(() => {
@@ -636,6 +739,8 @@ export const attachDrag = (el, opts) => {
636
739
  el: EL_ID,
637
740
  applied
638
741
  });
742
+ if (stopInertia)
743
+ stopInertia = null;
639
744
  })
640
745
  .catch(() => { })
641
746
  .finally(() => opts.callbacks?.onTransitionEnd?.());
@@ -643,10 +748,12 @@ export const attachDrag = (el, opts) => {
643
748
  opts.callbacks?.onEnd?.(e, computeInfo());
644
749
  endWhileDrag();
645
750
  };
646
- // Wire dragControls
751
+ // Wire dragControls. The cancelInertia thunk reads the *current*
752
+ // stopInertia at call time so the latest in-flight animation is
753
+ // targeted (stopInertia is re-assigned per finishDrag).
647
754
  if (opts.controls) {
648
755
  const internal = opts.controls;
649
- internal._bind?.(el, beginDrag);
756
+ internal._bind?.(el, beginDrag, () => stopInertia?.());
650
757
  pwLog('[drag] controls bound', { el: EL_ID });
651
758
  }
652
759
  el.addEventListener('pointerdown', onPointerDown);
@@ -34,3 +34,21 @@ export type ConstraintElastic = {
34
34
  * ```
35
35
  */
36
36
  export declare const applyConstraints: (point: number, range: ConstraintRange, elastic?: ConstraintElastic) => number;
37
+ /**
38
+ * Parse a CSS `matrix(a, b, c, d, tx, ty)` string into translate components.
39
+ *
40
+ * Used to read the rendered translate after Motion writes inline transforms
41
+ * during drag-cancel hooks.
42
+ *
43
+ * @param transform The computed `transform` style — typically `'none'` or
44
+ * `'matrix(1, 0, 0, 1, 10, 20)'`. `matrix3d(...)` is not supported.
45
+ * @returns The 2D translate components `{ tx, ty }`. Defaults to
46
+ * `{ tx: 0, ty: 0 }` for `'none'` or any non-matching input.
47
+ * @example
48
+ * parseMatrixTranslate('matrix(1, 0, 0, 1, 10, 20)') // → { tx: 10, ty: 20 }
49
+ * parseMatrixTranslate('none') // → { tx: 0, ty: 0 }
50
+ */
51
+ export declare const parseMatrixTranslate: (transform: string) => {
52
+ tx: number;
53
+ ty: number;
54
+ };
@@ -42,3 +42,24 @@ export const applyConstraints = (point, range, elastic) => {
42
42
  }
43
43
  return point;
44
44
  };
45
+ /**
46
+ * Parse a CSS `matrix(a, b, c, d, tx, ty)` string into translate components.
47
+ *
48
+ * Used to read the rendered translate after Motion writes inline transforms
49
+ * during drag-cancel hooks.
50
+ *
51
+ * @param transform The computed `transform` style — typically `'none'` or
52
+ * `'matrix(1, 0, 0, 1, 10, 20)'`. `matrix3d(...)` is not supported.
53
+ * @returns The 2D translate components `{ tx, ty }`. Defaults to
54
+ * `{ tx: 0, ty: 0 }` for `'none'` or any non-matching input.
55
+ * @example
56
+ * parseMatrixTranslate('matrix(1, 0, 0, 1, 10, 20)') // → { tx: 10, ty: 20 }
57
+ * parseMatrixTranslate('none') // → { tx: 0, ty: 0 }
58
+ */
59
+ export const parseMatrixTranslate = (transform) => {
60
+ const m = transform.match(/matrix\(([^)]+)\)/);
61
+ if (!m)
62
+ return { tx: 0, ty: 0 };
63
+ const parts = m[1].split(',').map((s) => Number.parseFloat(s.trim()));
64
+ return { tx: parts[4] ?? 0, ty: parts[5] ?? 0 };
65
+ };
@@ -80,12 +80,13 @@ export const createInertiaToBoundary = (initial, bounds, opts) => {
80
80
  let springX = x;
81
81
  let springV = 0;
82
82
  let boundaryTarget = null;
83
- // If starting OOB, skip inertia
83
+ // Starting OOB: skip inertia and engage the spring. Drop the
84
+ // away-from-boundary velocity component so the spring's first frames
85
+ // don't continue moving the value outward.
84
86
  if (x < min || x > max) {
85
87
  mode = 'spring';
86
- // Set spring start at current value and carry current velocity
87
88
  springX = x;
88
- springV = v;
89
+ springV = x < min ? Math.max(0, v) : Math.min(0, v);
89
90
  boundaryTarget = x < min ? min : max;
90
91
  }
91
92
  // Precompute first crossing (if any) from initial state