@humanspeak/svelte-motion 0.5.3 → 0.6.0

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,141 @@
1
+ import { type AnimationOptions } from 'motion';
2
+ import { optimizedAppearDataAttribute } from 'motion-dom';
3
+ type AppearValueName = 'opacity' | 'transform';
4
+ type OptimizedAppearEntry = {
5
+ name: AppearValueName;
6
+ keyframes: [string | number, string | number];
7
+ options: KeyframeAnimationOptions;
8
+ };
9
+ type AppearStoreEntry = {
10
+ animation: Animation;
11
+ startTime: number | null;
12
+ };
13
+ type SvelteMotionAppearStore = {
14
+ animations: Map<string, AppearStoreEntry>;
15
+ complete: Map<string, boolean>;
16
+ started: Array<{
17
+ id: string;
18
+ name: string;
19
+ }>;
20
+ readyAnimation?: Animation;
21
+ startFrameTime?: number;
22
+ };
23
+ declare global {
24
+ interface Window {
25
+ __SvelteMotionAppear?: SvelteMotionAppearStore;
26
+ }
27
+ }
28
+ /**
29
+ * Build serialisable optimized-appear animation entries from an initial and
30
+ * animate pair.
31
+ *
32
+ * @param initial Initial keyframes reflected into SSR markup.
33
+ * @param animate Target keyframes for the enter animation.
34
+ * @param transition Motion transition options.
35
+ * @returns Appear entries for WAAPI-supported opacity and transform values.
36
+ *
37
+ * @example
38
+ * ```ts
39
+ * const entries = createOptimizedAppearData(
40
+ * { opacity: 0, scale: 0.8 },
41
+ * { opacity: 1, scale: 1 },
42
+ * { duration: 0.6, ease: [0.16, 1, 0.3, 1] }
43
+ * )
44
+ * ```
45
+ */
46
+ export declare const createOptimizedAppearData: (initial: Record<string, unknown> | null | undefined, animate: Record<string, unknown> | null | undefined, transition?: AnimationOptions) => OptimizedAppearEntry[];
47
+ /**
48
+ * Create the inline SSR bootstrap that starts appear animations before Svelte
49
+ * hydrates the component tree.
50
+ *
51
+ * @param appearId Stable optimized-appear id attached to the motion element.
52
+ * @param entries WAAPI animation entries to start.
53
+ * @returns A script tag string, or an empty string when no entries exist.
54
+ *
55
+ * @example
56
+ * ```ts
57
+ * const script = createOptimizedAppearScript('appear-1', [
58
+ * { name: 'opacity', keyframes: [0, 1], options: { duration: 300, fill: 'both' } }
59
+ * ])
60
+ * ```
61
+ */
62
+ export declare const createOptimizedAppearScript: (appearId: string | undefined, entries: OptimizedAppearEntry[]) => string;
63
+ /**
64
+ * Start an optimized appear animation imperatively.
65
+ *
66
+ * Mirrors Framer Motion's `startOptimizedAppearAnimation`: if Motion has
67
+ * already mounted, this intentionally does nothing.
68
+ *
69
+ * @param element Element carrying `data-framer-appear-id`.
70
+ * @param name CSS property to animate.
71
+ * @param keyframes WAAPI keyframes for the property.
72
+ * @param options Motion animation options.
73
+ * @param onReady Optional callback receiving the started animation.
74
+ * @returns Nothing.
75
+ *
76
+ * @example
77
+ * ```ts
78
+ * const element = document.querySelector('[data-framer-appear-id]')
79
+ * if (element instanceof HTMLElement) {
80
+ * startOptimizedAppearAnimation(element, 'opacity', [0, 1], { duration: 0.3 })
81
+ * }
82
+ * ```
83
+ */
84
+ export declare const startOptimizedAppearAnimation: (element: HTMLElement, name: AppearValueName, keyframes: string[] | number[], options: AnimationOptions, onReady?: (animation: Animation) => void) => void;
85
+ /**
86
+ * Commit and cancel optimized appear animations for an element.
87
+ *
88
+ * @param elementId Optimized appear id.
89
+ * @returns `true` when at least one optimized animation was handed off.
90
+ *
91
+ * @example
92
+ * ```ts
93
+ * const wasHandedOff = handoffOptimizedAppearAnimation('appear-1')
94
+ * if (wasHandedOff) {
95
+ * console.log('Animation handed off to runtime')
96
+ * }
97
+ * ```
98
+ */
99
+ export declare const handoffOptimizedAppearAnimation: (elementId: string | undefined) => boolean;
100
+ /**
101
+ * Let active optimized appear animations finish before handing their final
102
+ * styles back to Svelte Motion.
103
+ *
104
+ * @param elementId Optimized appear id.
105
+ * @returns Whether at least one optimized animation was adopted.
106
+ *
107
+ * @example
108
+ * ```ts
109
+ * const wasAdopted = await finishOptimizedAppearAnimation('appear-1')
110
+ * if (wasAdopted) {
111
+ * console.log('Animation finished and adopted')
112
+ * }
113
+ * ```
114
+ */
115
+ export declare const finishOptimizedAppearAnimation: (elementId: string | undefined) => Promise<boolean>;
116
+ /**
117
+ * Check whether an optimized appear animation is active for an element.
118
+ *
119
+ * @param elementId Optimized appear id.
120
+ * @returns Whether any optimized appear animation is currently registered.
121
+ *
122
+ * @example
123
+ * ```ts
124
+ * if (hasOptimizedAppearAnimation('appear-1')) {
125
+ * console.log('Animation is active')
126
+ * }
127
+ * ```
128
+ */
129
+ export declare const hasOptimizedAppearAnimation: (elementId: string | undefined) => boolean;
130
+ /**
131
+ * Mark Motion as mounted so late optimized-appear starters no-op.
132
+ *
133
+ * @returns Nothing.
134
+ *
135
+ * @example
136
+ * ```ts
137
+ * markMotionMounted()
138
+ * ```
139
+ */
140
+ export declare const markMotionMounted: () => void;
141
+ export { optimizedAppearDataAttribute };
@@ -0,0 +1,311 @@
1
+ import { mergeInlineStyles } from './style';
2
+ import { resolveRestingValues } from './variants';
3
+ import { startWaapiAnimation } from 'motion';
4
+ import { mapEasingToNativeEasing, optimizedAppearDataAttribute, optimizedAppearDataId, transformProps } from 'motion-dom';
5
+ const appearStoreId = (elementId, valueName) => {
6
+ const key = transformProps.has(valueName) ? 'transform' : valueName;
7
+ return `${elementId}: ${key}`;
8
+ };
9
+ const getAppearStore = () => {
10
+ if (typeof window === 'undefined')
11
+ return undefined;
12
+ window.__SvelteMotionAppear ??= {
13
+ animations: new Map(),
14
+ complete: new Map(),
15
+ started: []
16
+ };
17
+ return window.__SvelteMotionAppear;
18
+ };
19
+ const installAppearGlobals = () => {
20
+ if (typeof window === 'undefined')
21
+ return;
22
+ const store = getAppearStore();
23
+ if (!store)
24
+ return;
25
+ window.MotionHasOptimisedAnimation ??= (elementId, valueName) => {
26
+ if (!elementId)
27
+ return false;
28
+ if (!valueName)
29
+ return store.complete.has(elementId);
30
+ return store.animations.has(appearStoreId(elementId, valueName));
31
+ };
32
+ window.MotionHandoffMarkAsComplete ??= (elementId) => {
33
+ if (store.complete.has(elementId)) {
34
+ store.complete.set(elementId, true);
35
+ }
36
+ };
37
+ window.MotionHandoffIsComplete ??= (elementId) => {
38
+ return store.complete.get(elementId) === true;
39
+ };
40
+ window.MotionCancelOptimisedAnimation ??= (elementId, valueName) => {
41
+ if (!elementId || !valueName)
42
+ return;
43
+ const animationId = appearStoreId(elementId, valueName);
44
+ const data = store.animations.get(animationId);
45
+ if (!data)
46
+ return;
47
+ data.animation.cancel();
48
+ store.animations.delete(animationId);
49
+ if (!store.animations.size) {
50
+ window.MotionCancelOptimisedAnimation = undefined;
51
+ }
52
+ };
53
+ };
54
+ const readStyleProp = (style, prop) => {
55
+ return style
56
+ .split(';')
57
+ .map((part) => part.trim())
58
+ .find((part) => part.startsWith(`${prop}:`))
59
+ ?.slice(prop.length + 1)
60
+ .trim();
61
+ };
62
+ const toNativeOptions = (transition) => {
63
+ const duration = typeof transition?.duration === 'number' ? transition.duration : 0.3;
64
+ const delay = typeof transition?.delay === 'number' ? transition.delay : 0;
65
+ const durationMs = duration * 1000;
66
+ const options = {
67
+ duration: durationMs,
68
+ delay: delay * 1000,
69
+ fill: 'both'
70
+ };
71
+ const easing = mapEasingToNativeEasing(transition?.ease, durationMs);
72
+ if (Array.isArray(easing)) {
73
+ options.easing = easing[0] ?? 'linear';
74
+ }
75
+ else if (easing) {
76
+ options.easing = easing;
77
+ }
78
+ return options;
79
+ };
80
+ /**
81
+ * Build serialisable optimized-appear animation entries from an initial and
82
+ * animate pair.
83
+ *
84
+ * @param initial Initial keyframes reflected into SSR markup.
85
+ * @param animate Target keyframes for the enter animation.
86
+ * @param transition Motion transition options.
87
+ * @returns Appear entries for WAAPI-supported opacity and transform values.
88
+ *
89
+ * @example
90
+ * ```ts
91
+ * const entries = createOptimizedAppearData(
92
+ * { opacity: 0, scale: 0.8 },
93
+ * { opacity: 1, scale: 1 },
94
+ * { duration: 0.6, ease: [0.16, 1, 0.3, 1] }
95
+ * )
96
+ * ```
97
+ */
98
+ export const createOptimizedAppearData = (initial, animate, transition) => {
99
+ if (!initial || !animate)
100
+ return [];
101
+ const target = resolveRestingValues(animate);
102
+ if (!target)
103
+ return [];
104
+ const options = toNativeOptions(transition);
105
+ const entries = [];
106
+ if (initial.opacity != null && target.opacity != null) {
107
+ entries.push({
108
+ name: 'opacity',
109
+ keyframes: [
110
+ Array.isArray(initial.opacity) ? initial.opacity[0] : initial.opacity,
111
+ Array.isArray(target.opacity) ? target.opacity[0] : target.opacity
112
+ ],
113
+ options
114
+ });
115
+ }
116
+ const initialTransform = readStyleProp(mergeInlineStyles('', initial), 'transform');
117
+ const targetTransform = readStyleProp(mergeInlineStyles('', target), 'transform');
118
+ if (initialTransform && targetTransform && initialTransform !== targetTransform) {
119
+ entries.push({
120
+ name: 'transform',
121
+ keyframes: [initialTransform, targetTransform],
122
+ options
123
+ });
124
+ }
125
+ return entries;
126
+ };
127
+ /**
128
+ * Create the inline SSR bootstrap that starts appear animations before Svelte
129
+ * hydrates the component tree.
130
+ *
131
+ * @param appearId Stable optimized-appear id attached to the motion element.
132
+ * @param entries WAAPI animation entries to start.
133
+ * @returns A script tag string, or an empty string when no entries exist.
134
+ *
135
+ * @example
136
+ * ```ts
137
+ * const script = createOptimizedAppearScript('appear-1', [
138
+ * { name: 'opacity', keyframes: [0, 1], options: { duration: 300, fill: 'both' } }
139
+ * ])
140
+ * ```
141
+ */
142
+ export const createOptimizedAppearScript = (appearId, entries) => {
143
+ if (!appearId || entries.length === 0)
144
+ return '';
145
+ const payload = JSON.stringify({ id: appearId, entries }).replace(/</g, '\\u003c');
146
+ return `<script>(()=>{const p=${payload},w=window;if(w.MotionIsMounted)return;const q=String(p.id).replace(/["\\\\]/g,"\\\\$&");const e=document.querySelector('[${optimizedAppearDataAttribute}="'+q+'"]');if(!e||!e.animate)return;const s=w.__SvelteMotionAppear||(w.__SvelteMotionAppear={animations:new Map,complete:new Map,started:[]});const k=(id,n)=>id+": "+(n==="transform"?"transform":n);w.MotionHasOptimisedAnimation=w.MotionHasOptimisedAnimation||((id,n)=>id?n?s.animations.has(k(id,n)):s.complete.has(id):false);w.MotionHandoffMarkAsComplete=w.MotionHandoffMarkAsComplete||((id)=>{if(s.complete.has(id))s.complete.set(id,true)});w.MotionHandoffIsComplete=w.MotionHandoffIsComplete||((id)=>s.complete.get(id)===true);w.MotionCancelOptimisedAnimation=w.MotionCancelOptimisedAnimation||((id,n)=>{const key=k(id,n),d=s.animations.get(key);if(!d)return;d.animation.cancel();s.animations.delete(key);if(!s.animations.size)w.MotionCancelOptimisedAnimation=undefined});s.complete.set(p.id,false);for(const a of p.entries){const key=k(p.id,a.name);if(!s.readyAnimation){s.readyAnimation=e.animate({[a.name]:[a.keyframes[0],a.keyframes[0]]},{duration:1e4,easing:"linear",fill:"both"});s.animations.set(key,{animation:s.readyAnimation,startTime:null})}const start=()=>{s.readyAnimation.cancel();let t=s.startFrameTime;if(t===undefined){t=performance.now();s.startFrameTime=t}const anim=e.animate({[a.name]:a.keyframes},a.options);anim.startTime=t;s.animations.set(key,{animation:anim,startTime:t});s.started.push({id:p.id,name:a.name})};const r=s.readyAnimation;r.ready?r.ready.then(start).catch(()=>{}):start()}})();</script>`;
147
+ };
148
+ /**
149
+ * Start an optimized appear animation imperatively.
150
+ *
151
+ * Mirrors Framer Motion's `startOptimizedAppearAnimation`: if Motion has
152
+ * already mounted, this intentionally does nothing.
153
+ *
154
+ * @param element Element carrying `data-framer-appear-id`.
155
+ * @param name CSS property to animate.
156
+ * @param keyframes WAAPI keyframes for the property.
157
+ * @param options Motion animation options.
158
+ * @param onReady Optional callback receiving the started animation.
159
+ * @returns Nothing.
160
+ *
161
+ * @example
162
+ * ```ts
163
+ * const element = document.querySelector('[data-framer-appear-id]')
164
+ * if (element instanceof HTMLElement) {
165
+ * startOptimizedAppearAnimation(element, 'opacity', [0, 1], { duration: 0.3 })
166
+ * }
167
+ * ```
168
+ */
169
+ export const startOptimizedAppearAnimation = (element, name, keyframes, options, onReady) => {
170
+ if (typeof window === 'undefined' || window.MotionIsMounted)
171
+ return;
172
+ const id = element.dataset[optimizedAppearDataId];
173
+ if (!id)
174
+ return;
175
+ installAppearGlobals();
176
+ const store = getAppearStore();
177
+ if (!store)
178
+ return;
179
+ const storeId = appearStoreId(id, name);
180
+ if (!store.readyAnimation) {
181
+ store.readyAnimation = startWaapiAnimation(element, name, [keyframes[0], keyframes[0]], {
182
+ duration: 10000,
183
+ ease: 'linear'
184
+ });
185
+ store.animations.set(storeId, { animation: store.readyAnimation, startTime: null });
186
+ }
187
+ const startAnimation = () => {
188
+ store.readyAnimation?.cancel();
189
+ store.startFrameTime ??= performance.now();
190
+ const animation = startWaapiAnimation(element, name, keyframes, options);
191
+ animation.startTime = store.startFrameTime;
192
+ store.animations.set(storeId, { animation, startTime: store.startFrameTime });
193
+ store.started.push({ id, name });
194
+ onReady?.(animation);
195
+ };
196
+ store.complete.set(id, false);
197
+ const readyAnimation = store.readyAnimation;
198
+ if (readyAnimation.ready) {
199
+ readyAnimation.ready.then(startAnimation).catch(() => { });
200
+ }
201
+ else {
202
+ startAnimation();
203
+ }
204
+ };
205
+ /**
206
+ * Commit and cancel optimized appear animations for an element.
207
+ *
208
+ * @param elementId Optimized appear id.
209
+ * @returns `true` when at least one optimized animation was handed off.
210
+ *
211
+ * @example
212
+ * ```ts
213
+ * const wasHandedOff = handoffOptimizedAppearAnimation('appear-1')
214
+ * if (wasHandedOff) {
215
+ * console.log('Animation handed off to runtime')
216
+ * }
217
+ * ```
218
+ */
219
+ export const handoffOptimizedAppearAnimation = (elementId) => {
220
+ if (!elementId || typeof window === 'undefined')
221
+ return false;
222
+ const store = getAppearStore();
223
+ if (!store)
224
+ return false;
225
+ let handedOff = false;
226
+ for (const [key, data] of [...store.animations]) {
227
+ if (!key.startsWith(`${elementId}: `))
228
+ continue;
229
+ data.animation.commitStyles?.();
230
+ data.animation.cancel();
231
+ store.animations.delete(key);
232
+ handedOff = true;
233
+ }
234
+ if (store.complete.has(elementId)) {
235
+ store.complete.set(elementId, true);
236
+ }
237
+ return handedOff;
238
+ };
239
+ /**
240
+ * Let active optimized appear animations finish before handing their final
241
+ * styles back to Svelte Motion.
242
+ *
243
+ * @param elementId Optimized appear id.
244
+ * @returns Whether at least one optimized animation was adopted.
245
+ *
246
+ * @example
247
+ * ```ts
248
+ * const wasAdopted = await finishOptimizedAppearAnimation('appear-1')
249
+ * if (wasAdopted) {
250
+ * console.log('Animation finished and adopted')
251
+ * }
252
+ * ```
253
+ */
254
+ export const finishOptimizedAppearAnimation = async (elementId) => {
255
+ if (!elementId || typeof window === 'undefined')
256
+ return false;
257
+ const store = getAppearStore();
258
+ if (!store)
259
+ return false;
260
+ let entries = [...store.animations].filter(([key]) => key.startsWith(`${elementId}: `));
261
+ if (!entries.length)
262
+ return false;
263
+ await Promise.all(entries.map(([, data]) => data.startTime === null ? data.animation.ready?.catch(() => undefined) : undefined));
264
+ entries = [...store.animations].filter(([key]) => key.startsWith(`${elementId}: `));
265
+ await Promise.all(entries.map(([, data]) => data.animation.finished.catch(() => undefined)));
266
+ for (const [key, data] of entries) {
267
+ if (!store.animations.has(key))
268
+ continue;
269
+ data.animation.commitStyles?.();
270
+ data.animation.cancel();
271
+ store.animations.delete(key);
272
+ }
273
+ if (store.complete.has(elementId)) {
274
+ store.complete.set(elementId, true);
275
+ }
276
+ return true;
277
+ };
278
+ /**
279
+ * Check whether an optimized appear animation is active for an element.
280
+ *
281
+ * @param elementId Optimized appear id.
282
+ * @returns Whether any optimized appear animation is currently registered.
283
+ *
284
+ * @example
285
+ * ```ts
286
+ * if (hasOptimizedAppearAnimation('appear-1')) {
287
+ * console.log('Animation is active')
288
+ * }
289
+ * ```
290
+ */
291
+ export const hasOptimizedAppearAnimation = (elementId) => {
292
+ if (!elementId || typeof window === 'undefined')
293
+ return false;
294
+ return window.MotionHasOptimisedAnimation?.(elementId) ?? false;
295
+ };
296
+ /**
297
+ * Mark Motion as mounted so late optimized-appear starters no-op.
298
+ *
299
+ * @returns Nothing.
300
+ *
301
+ * @example
302
+ * ```ts
303
+ * markMotionMounted()
304
+ * ```
305
+ */
306
+ export const markMotionMounted = () => {
307
+ if (typeof window !== 'undefined') {
308
+ window.MotionIsMounted = true;
309
+ }
310
+ };
311
+ export { optimizedAppearDataAttribute };
@@ -16,10 +16,11 @@ export type AnimatePresenceContext = {
16
16
  */
17
17
  shouldAnimateEnter: (key: string) => boolean;
18
18
  /**
19
- * For mode='wait': Returns true if enters should be blocked (exits in progress).
19
+ * For mode='wait': Returns true if enters should be blocked by an exiting
20
+ * or currently-present sibling.
20
21
  * Motion elements should delay their enter animation until this returns false.
21
22
  */
22
- isEnterBlocked: () => boolean;
23
+ isEnterBlocked: (key?: string) => boolean;
23
24
  /**
24
25
  * For mode='wait': Register a callback to be invoked when enters are unblocked.
25
26
  * Returns an unsubscribe function.
@@ -31,6 +31,15 @@ const resetTransforms = (element) => {
31
31
  s.MozTransform = 'none';
32
32
  s.OTransform = 'none';
33
33
  };
34
+ const findLayoutInsertionParent = (element) => {
35
+ let before = element;
36
+ let parent = element.parentElement;
37
+ while (parent && getComputedStyle(parent).display === 'contents') {
38
+ before = parent;
39
+ parent = parent.parentElement;
40
+ }
41
+ return parent ? { parent, before } : null;
42
+ };
34
43
  /**
35
44
  * Create a new `AnimatePresence` context instance.
36
45
  *
@@ -130,10 +139,20 @@ export const createAnimatePresenceContext = (context) => {
130
139
  * }
131
140
  * ```
132
141
  */
133
- const isEnterBlocked = () => {
134
- // For wait mode, block enters only when there are actual in-flight exits
135
- const blocked = mode === 'wait' && inFlightExits > 0;
136
- pwLog('[presence] isEnterBlocked', { blocked, mode, inFlightExits });
142
+ const isEnterBlocked = (key) => {
143
+ if (mode !== 'wait') {
144
+ pwLog('[presence] isEnterBlocked', { blocked: false, mode, inFlightExits, key });
145
+ return false;
146
+ }
147
+ const hasBlockingSibling = key !== undefined && Array.from(children.keys()).some((childKey) => childKey !== key);
148
+ const blocked = inFlightExits > 0 || hasBlockingSibling;
149
+ pwLog('[presence] isEnterBlocked', {
150
+ blocked,
151
+ mode,
152
+ inFlightExits,
153
+ key,
154
+ hasBlockingSibling
155
+ });
137
156
  return blocked;
138
157
  };
139
158
  /**
@@ -286,7 +305,8 @@ export const createAnimatePresenceContext = (context) => {
286
305
  exit,
287
306
  mergedTransition,
288
307
  lastRect: initialRect,
289
- lastComputedStyle: initialStyle
308
+ lastComputedStyle: initialStyle,
309
+ layoutInsertion: findLayoutInsertionParent(element) ?? undefined
290
310
  });
291
311
  };
292
312
  /**
@@ -337,12 +357,14 @@ export const createAnimatePresenceContext = (context) => {
337
357
  }
338
358
  const rect = child.lastRect;
339
359
  const computed = child.lastComputedStyle;
340
- // For sync/wait, preserve layout by inserting a hidden placeholder.
341
- // For popLayout, we remove from layout immediately (no placeholder).
342
- const shouldPreserveLayout = mode !== 'popLayout';
360
+ // Wait mode holds the exiting layout slot while the entering child is hidden.
361
+ // Sync and popLayout must not add an extra layout participant.
362
+ const shouldPreserveLayout = mode === 'wait';
343
363
  let placeholder = null;
344
- const layoutParent = child.element.parentElement;
345
- if (shouldPreserveLayout && layoutParent) {
364
+ const liveLayoutInsertion = findLayoutInsertionParent(child.element);
365
+ const layoutInsertion = liveLayoutInsertion ??
366
+ (child.layoutInsertion?.parent.isConnected ? child.layoutInsertion : null);
367
+ if (shouldPreserveLayout && layoutInsertion) {
346
368
  placeholder = document.createElement(child.element.tagName.toLowerCase());
347
369
  placeholder.setAttribute('data-presence-placeholder', 'true');
348
370
  placeholder.style.display = computed.display === 'contents' ? 'block' : computed.display;
@@ -359,7 +381,22 @@ export const createAnimatePresenceContext = (context) => {
359
381
  if (computed.alignSelf) {
360
382
  placeholder.style.alignSelf = computed.alignSelf;
361
383
  }
362
- layoutParent.insertBefore(placeholder, child.element);
384
+ if (computed.gridColumnStart) {
385
+ placeholder.style.gridColumnStart = computed.gridColumnStart;
386
+ }
387
+ if (computed.gridColumnEnd) {
388
+ placeholder.style.gridColumnEnd = computed.gridColumnEnd;
389
+ }
390
+ if (computed.gridRowStart) {
391
+ placeholder.style.gridRowStart = computed.gridRowStart;
392
+ }
393
+ if (computed.gridRowEnd) {
394
+ placeholder.style.gridRowEnd = computed.gridRowEnd;
395
+ }
396
+ const before = layoutInsertion.before?.parentElement === layoutInsertion.parent
397
+ ? layoutInsertion.before
398
+ : null;
399
+ layoutInsertion.parent.insertBefore(placeholder, before);
363
400
  }
364
401
  // Clone original node to preserve structure/classes, then inline computed styles to freeze look
365
402
  const clone = child.element.cloneNode(true);
@@ -485,7 +522,6 @@ export const createAnimatePresenceContext = (context) => {
485
522
  // ignore
486
523
  }
487
524
  clone.remove();
488
- placeholder?.remove();
489
525
  // Log clone removal and element counts for debugging rapid toggle
490
526
  pwLog('[presence] clone REMOVED from DOM', {
491
527
  key,
@@ -514,6 +550,7 @@ export const createAnimatePresenceContext = (context) => {
514
550
  inFlightExits: inFlightExits - 1,
515
551
  clonesInDOM: document.querySelectorAll('[data-clone="true"]').length
516
552
  });
553
+ placeholder?.remove();
517
554
  finishExit();
518
555
  });
519
556
  });
@@ -78,9 +78,9 @@ export interface ProjectionNodeOptions {
78
78
  /**
79
79
  * Thunk returning the `layoutScroll` ancestor chain at measure
80
80
  * time. Used as the second argument to `measureRect`, which
81
- * shifts the returned rect by the sum of ancestor
82
- * `scrollLeft`/`scrollTop` so FLIP deltas stay correct when
83
- * scrollable ancestors scroll between two measurements.
81
+ * shifts the returned rect by the viewport scroll plus the sum of
82
+ * ancestor `scrollLeft`/`scrollTop` so FLIP deltas stay correct
83
+ * when the page or scrollable ancestors scroll between two measurements.
84
84
  *
85
85
  * Returning `[]` (or omitting the option entirely) gives
86
86
  * viewport-relative measurements — fine for the common case.
@@ -236,7 +236,7 @@ export class ProjectionNode {
236
236
  for (const { el, base } of restoreList)
237
237
  el.style.transform = base;
238
238
  // measureRect applies self's base transform + scroll-container offset.
239
- const rect = measureRect(this.element, this.getScrollContainers?.() ?? [], this.resolveBaseTransform());
239
+ const rect = measureRect(this.element, this.getScrollContainers?.() ?? [], this.resolveBaseTransform(), true);
240
240
  const box = rectToBox(rect);
241
241
  this.latestLayout = box;
242
242
  // Clone for the event: `box` aliases `this.latestLayout`, so a
@@ -69,8 +69,8 @@ export declare const isSVGElement: (element: Element) => element is SVGElement;
69
69
  *
70
70
  * Normalized behavior (React/Framer Motion parity):
71
71
  * - Ensures `pathLength="1"` is set when any path prop is present
72
- * - Maps `pathLength`/`pathSpacing` → `stroke-dasharray` (px)
73
- * - Maps `pathOffset` → `stroke-dashoffset` (negative px)
72
+ * - Maps `pathLength`/`pathSpacing` → unitless `stroke-dasharray`
73
+ * - Maps `pathOffset` → unitless negative `stroke-dashoffset`
74
74
  *
75
75
  * @param {Element} element The element being animated (must be an SVG path).
76
76
  * @param {Record<string, unknown>} keyframes The input keyframes possibly containing path props.
@@ -105,8 +105,8 @@ export declare const transformInitialSVGPathProperties: (element: Element, initi
105
105
  *
106
106
  * Behavior matches React/Framer Motion parity:
107
107
  * - Always sets pathLength="1" whenever any of path props are present
108
- * - stroke-dasharray = px(pathLength) + ' ' + px(pathSpacing ?? 1 - Number(pathLength))
109
- * - stroke-dashoffset = px(-(pathOffset ?? 0))
108
+ * - stroke-dasharray = pathLength + ' ' + (pathSpacing ?? 1)
109
+ * - stroke-dashoffset = -(pathOffset ?? 0)
110
110
  *
111
111
  * The returned object is suitable for direct DOM attribute assignment (dash-cased keys).
112
112
  *