@humanspeak/svelte-motion 0.4.3 → 0.4.5

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.
package/README.md CHANGED
@@ -214,6 +214,29 @@ Supported drag props:
214
214
 
215
215
  - String variant keys are resolved from `variants`.
216
216
  - Variant state inherits through context.
217
+ - A variant entry can be a `(custom) => keyframes` factory. The `custom` prop is forwarded — useful for staggered lists where each child needs its own offset or delay. Children without `custom` inherit the nearest motion ancestor's value.
218
+
219
+ ```svelte
220
+ <script lang="ts">
221
+ import { motion, type Variants } from '@humanspeak/svelte-motion'
222
+
223
+ const variants: Variants = {
224
+ hidden: { opacity: 0, x: -100 },
225
+ visible: (i) => ({
226
+ opacity: 1,
227
+ x: 0,
228
+ transition: { delay: (i as number) * 0.1 }
229
+ })
230
+ }
231
+ const items = ['Alpha', 'Beta', 'Gamma', 'Delta']
232
+ </script>
233
+
234
+ {#each items as item, i}
235
+ <motion.li custom={i} {variants} initial="hidden" animate="visible">
236
+ {item}
237
+ </motion.li>
238
+ {/each}
239
+ ```
217
240
 
218
241
  ## Layout animation
219
242
 
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Deferred references to the chain of `layoutScroll` ancestors for the
3
+ * current subtree. Returned as a thunk because element refs are bound
4
+ * after mount; consumers invoke at measurement time.
5
+ *
6
+ * Order is closest-first. Order doesn't matter for the current scroll-
7
+ * offset sum, but is preserved so future per-container semantics (e.g.
8
+ * a `scroll.wasRoot` marker like framer-motion) can iterate deterministically.
9
+ */
10
+ export type LayoutScrollContainerRef = () => Array<HTMLElement | null | undefined>;
11
+ /**
12
+ * Publish the scroll-container chain for descendant motion components.
13
+ *
14
+ * Called on a `motion.*` component with `layoutScroll` enabled during
15
+ * its init phase. The provided thunk should resolve to `[...ancestorChain,
16
+ * ownElement]` — descendants get the full chain in one call.
17
+ *
18
+ * Mirrors framer-motion's `removeElementScroll`, which walks the path
19
+ * and sums every `layoutScroll` ancestor's offset.
20
+ *
21
+ * @param ref Thunk returning the full ancestor chain (closest first).
22
+ */
23
+ export declare const setLayoutScrollContainer: (ref: LayoutScrollContainerRef) => void;
24
+ /**
25
+ * Capture the ancestor chain thunk at component init.
26
+ *
27
+ * Important: call this **before** the same component calls
28
+ * `setLayoutScrollContainer(...)`. Otherwise the lookup returns the
29
+ * component's own thunk (Svelte `setContext` shadows from the call site
30
+ * down) and the chain collapses.
31
+ *
32
+ * Returns `undefined` when no ancestor has `layoutScroll`.
33
+ *
34
+ * @returns Ancestor chain thunk, or `undefined`.
35
+ */
36
+ export declare const getLayoutScrollContainerRef: () => LayoutScrollContainerRef | undefined;
@@ -0,0 +1,32 @@
1
+ import { getContext, setContext } from 'svelte';
2
+ const LAYOUT_SCROLL_CONTEXT_KEY = Symbol('layout-scroll-container');
3
+ /**
4
+ * Publish the scroll-container chain for descendant motion components.
5
+ *
6
+ * Called on a `motion.*` component with `layoutScroll` enabled during
7
+ * its init phase. The provided thunk should resolve to `[...ancestorChain,
8
+ * ownElement]` — descendants get the full chain in one call.
9
+ *
10
+ * Mirrors framer-motion's `removeElementScroll`, which walks the path
11
+ * and sums every `layoutScroll` ancestor's offset.
12
+ *
13
+ * @param ref Thunk returning the full ancestor chain (closest first).
14
+ */
15
+ export const setLayoutScrollContainer = (ref) => {
16
+ setContext(LAYOUT_SCROLL_CONTEXT_KEY, ref);
17
+ };
18
+ /**
19
+ * Capture the ancestor chain thunk at component init.
20
+ *
21
+ * Important: call this **before** the same component calls
22
+ * `setLayoutScrollContainer(...)`. Otherwise the lookup returns the
23
+ * component's own thunk (Svelte `setContext` shadows from the call site
24
+ * down) and the chain collapses.
25
+ *
26
+ * Returns `undefined` when no ancestor has `layoutScroll`.
27
+ *
28
+ * @returns Ancestor chain thunk, or `undefined`.
29
+ */
30
+ export const getLayoutScrollContainerRef = () => {
31
+ return getContext(LAYOUT_SCROLL_CONTEXT_KEY);
32
+ };
@@ -25,3 +25,23 @@ export declare const setInitialFalseContext: (value: boolean) => void;
25
25
  * @returns `true` if a parent set `initial={false}`, otherwise `false`.
26
26
  */
27
27
  export declare const getInitialFalseContext: () => boolean;
28
+ /**
29
+ * Provide a writable store carrying the current motion component's
30
+ * `custom` value so descendant motion components without their own
31
+ * `custom` prop can inherit it — and re-resolve their variants when the
32
+ * parent's `custom` changes.
33
+ *
34
+ * Mirrors framer-motion's variant-tree custom propagation. A store
35
+ * (rather than a snapshot) lets descendants react to changes the parent
36
+ * makes after mount.
37
+ *
38
+ * @param store Writable store holding the current component's effective `custom`.
39
+ */
40
+ export declare const setCustomContext: (store: Writable<unknown>) => void;
41
+ /**
42
+ * Read the nearest ancestor's `custom` store (if any).
43
+ *
44
+ * @returns The ancestor's writable store, or `undefined` when no motion
45
+ * ancestor has set one.
46
+ */
47
+ export declare const getCustomContext: () => Writable<unknown> | undefined;
@@ -1,6 +1,7 @@
1
1
  import { getContext, setContext } from 'svelte';
2
2
  const VARIANT_CONTEXT_KEY = Symbol('variant-context');
3
3
  const INITIAL_FALSE_CONTEXT_KEY = Symbol('initial-false-context');
4
+ const CUSTOM_CONTEXT_KEY = Symbol('custom-context');
4
5
  /**
5
6
  * Provide a writable store for the current variant key so children can
6
7
  * react to changes over time (true inheritance like Framer Motion).
@@ -35,3 +36,27 @@ export const setInitialFalseContext = (value) => {
35
36
  export const getInitialFalseContext = () => {
36
37
  return getContext(INITIAL_FALSE_CONTEXT_KEY) ?? false;
37
38
  };
39
+ /**
40
+ * Provide a writable store carrying the current motion component's
41
+ * `custom` value so descendant motion components without their own
42
+ * `custom` prop can inherit it — and re-resolve their variants when the
43
+ * parent's `custom` changes.
44
+ *
45
+ * Mirrors framer-motion's variant-tree custom propagation. A store
46
+ * (rather than a snapshot) lets descendants react to changes the parent
47
+ * makes after mount.
48
+ *
49
+ * @param store Writable store holding the current component's effective `custom`.
50
+ */
51
+ export const setCustomContext = (store) => {
52
+ setContext(CUSTOM_CONTEXT_KEY, store);
53
+ };
54
+ /**
55
+ * Read the nearest ancestor's `custom` store (if any).
56
+ *
57
+ * @returns The ancestor's writable store, or `undefined` when no motion
58
+ * ancestor has set one.
59
+ */
60
+ export const getCustomContext = () => {
61
+ return getContext(CUSTOM_CONTEXT_KEY);
62
+ };
@@ -53,7 +53,9 @@
53
53
  setVariantContext,
54
54
  getVariantContext,
55
55
  setInitialFalseContext,
56
- getInitialFalseContext
56
+ getInitialFalseContext,
57
+ setCustomContext,
58
+ getCustomContext
57
59
  } from '../components/variantContext.context'
58
60
  import { get, writable } from 'svelte/store'
59
61
  import {
@@ -63,6 +65,10 @@
63
65
  SVG_NAMESPACE
64
66
  } from '../utils/svg'
65
67
  import { getLayoutIdRegistry } from '../utils/layoutId'
68
+ import {
69
+ getLayoutScrollContainerRef,
70
+ setLayoutScrollContainer
71
+ } from '../components/layoutScroll.context'
66
72
 
67
73
  type Props = MotionProps & {
68
74
  children?: Snippet
@@ -75,6 +81,7 @@
75
81
  tag = 'div',
76
82
  key: keyProp,
77
83
  variants: variantsProp,
84
+ custom: customProp,
78
85
  initial: initialProp,
79
86
  animate: animateProp,
80
87
  exit: exitProp,
@@ -115,6 +122,7 @@
115
122
  dragControls: dragControlsProp,
116
123
  layout: layoutProp,
117
124
  layoutId: layoutIdProp,
125
+ layoutScroll: layoutScrollProp,
118
126
  ref: element = $bindable(null),
119
127
  ...rest
120
128
  }: Props = $props()
@@ -139,6 +147,30 @@
139
147
  // Get layoutId registry (provided by AnimatePresence or a parent LayoutGroup)
140
148
  const layoutIdRegistry = getLayoutIdRegistry()
141
149
 
150
+ // Capture the ancestor `layoutScroll` chain BEFORE we potentially shadow
151
+ // the context with ourselves below — this element's own FLIP measurements
152
+ // must resolve against the *ancestors*' scroll containers, not against
153
+ // itself.
154
+ //
155
+ // We walk the full chain (not just the nearest) so a `layoutScroll`
156
+ // outside another `layoutScroll` still contributes to descendant
157
+ // measurements — matches framer-motion's `removeElementScroll` walking
158
+ // `this.path`.
159
+ const ancestorScrollContainerRef = getLayoutScrollContainerRef()
160
+ if (layoutScrollProp) {
161
+ // Publish [...ancestorChain, ownElement]. The chain is collected
162
+ // lazily because element refs bind after mount.
163
+ setLayoutScrollContainer(() => {
164
+ const inherited = ancestorScrollContainerRef?.() ?? []
165
+ return element ? [...inherited, element] : inherited
166
+ })
167
+ }
168
+ const resolveLayoutScrollAncestors = (): HTMLElement[] => {
169
+ const refs = ancestorScrollContainerRef?.() ?? []
170
+ // Filter out unbound refs (HTMLElement | null | undefined → HTMLElement[]).
171
+ return refs.filter((el): el is HTMLElement => Boolean(el))
172
+ }
173
+
142
174
  // Get current presence depth (0 = direct child of AnimatePresence, undefined = not in AnimatePresence)
143
175
  const presenceDepth = getPresenceDepth()
144
176
 
@@ -210,11 +242,14 @@
210
242
  $effect(() => {
211
243
  if (!(element && layoutIdProp && layoutIdRegistry)) return
212
244
 
213
- // Capture rect on every frame while mounted
245
+ // Capture rect on every frame while mounted. Re-express in the
246
+ // nearest layoutScroll ancestor's coordinate space so the FLIP-from
247
+ // rect stored at unmount stays correct even if the scroll container
248
+ // moved between the snapshot and the next element's mount.
214
249
  let rafId: number
215
250
  const captureRect = () => {
216
251
  if (element) {
217
- layoutIdLastRect = element.getBoundingClientRect()
252
+ layoutIdLastRect = measureRect(element, resolveLayoutScrollAncestors())
218
253
  }
219
254
  rafId = requestAnimationFrame(captureRect)
220
255
  }
@@ -360,6 +395,32 @@
360
395
  // Provide context immediately during initialization so children can inherit
361
396
  setVariantContext(localVariantStore)
362
397
 
398
+ // Custom-value inheritance. Children with no `custom` prop adopt the
399
+ // nearest motion ancestor's value. Reactive via a writable store so a
400
+ // parent updating `custom` re-fires descendants' variant resolution.
401
+ const parentCustomStore = getCustomContext()
402
+ let inheritedCustom: unknown = undefined
403
+ if (parentCustomStore) {
404
+ parentCustomStore.subscribe((v) => (inheritedCustom = v))()
405
+ }
406
+ const initialCustomValue = customProp !== undefined ? customProp : inheritedCustom
407
+ const localCustomStore = writable<unknown>(initialCustomValue)
408
+ setCustomContext(localCustomStore)
409
+
410
+ let parentInheritedCustom = $state<unknown>(inheritedCustom)
411
+ $effect(() => {
412
+ if (!parentCustomStore) {
413
+ parentInheritedCustom = undefined
414
+ return
415
+ }
416
+ const unsubscribe = parentCustomStore.subscribe((v) => (parentInheritedCustom = v))
417
+ return () => unsubscribe()
418
+ })
419
+ const effectiveCustom = $derived(customProp !== undefined ? customProp : parentInheritedCustom)
420
+ $effect(() => {
421
+ localCustomStore.set(effectiveCustom)
422
+ })
423
+
363
424
  $effect(() => {
364
425
  if (!variantsProp) return localVariantStore.set(undefined)
365
426
  if (typeof animateProp === 'string') return localVariantStore.set(animateProp)
@@ -367,9 +428,13 @@
367
428
  localVariantStore.set(undefined)
368
429
  })
369
430
 
370
- const resolvedInitial = $derived(resolveInitial(effectiveInitialProp, variantsProp))
371
- const resolvedAnimate = $derived(resolveAnimate(effectiveAnimate, variantsProp))
372
- const resolvedExit = $derived(resolveExit(exitProp, variantsProp))
431
+ const resolvedInitial = $derived(
432
+ resolveInitial(effectiveInitialProp, variantsProp, effectiveCustom)
433
+ )
434
+ const resolvedAnimate = $derived(
435
+ resolveAnimate(effectiveAnimate, variantsProp, effectiveCustom)
436
+ )
437
+ const resolvedExit = $derived(resolveExit(exitProp, variantsProp, effectiveCustom))
373
438
 
374
439
  // Extract keyframes from resolved initial, handling initial={false}
375
440
  const initialKeyframes = $derived(
@@ -639,6 +704,12 @@
639
704
 
640
705
  // Track the last variant key we ran to avoid re-running on mount
641
706
  let lastRanVariantKey = $state<string | undefined>(undefined)
707
+ // Companion to `lastRanVariantKey`: the JSON-serialized resolved
708
+ // keyframes for that variant. Lets us detect when a function-form
709
+ // variant produces new keyframes (because `custom` changed) while
710
+ // the variant key stayed the same — otherwise the animate effect
711
+ // would short-circuit and the element would never re-animate.
712
+ let lastRanResolvedJson = $state<string | undefined>(undefined)
642
713
  let mountedWithInitialFalse = $state(false)
643
714
  // Track if the initial->animate transition has already been triggered by main effect
644
715
  let initialAnimationTriggered = $state(false)
@@ -662,17 +733,18 @@
662
733
  if (!(element && layoutProp && isLoaded === 'ready')) return
663
734
 
664
735
  // Initialize last rect on first ready frame
665
- lastRect = measureRect(element!)
736
+ lastRect = measureRect(element!, resolveLayoutScrollAncestors())
666
737
  // Hint compositor for smoother FLIP transforms
667
738
  setCompositorHints(element!, true)
668
739
 
669
740
  let rafId: number | null = null
670
741
  const runFlip = () => {
742
+ const scrollContainers = resolveLayoutScrollAncestors()
671
743
  if (!lastRect) {
672
- lastRect = measureRect(element!)
744
+ lastRect = measureRect(element!, scrollContainers)
673
745
  return
674
746
  }
675
- const next = measureRect(element!)
747
+ const next = measureRect(element!, scrollContainers)
676
748
  const transforms = computeFlipTransforms(lastRect, next, layoutProp ?? false)
677
749
  runFlipAnimation(element!, transforms, (mergedTransition ?? {}) as AnimationOptions)
678
750
  lastRect = next
@@ -706,7 +778,7 @@
706
778
  const prev = layoutIdRegistry.consume(layoutIdProp)
707
779
  if (!prev) return // First appearance, no animation needed
708
780
 
709
- const next = measureRect(element)
781
+ const next = measureRect(element, resolveLayoutScrollAncestors())
710
782
  const transforms = computeFlipTransforms(prev.rect, next, true)
711
783
 
712
784
  setCompositorHints(element, true)
@@ -916,8 +988,14 @@
916
988
  return
917
989
  }
918
990
  if (typeof animateProp === 'string') {
919
- if (lastRanVariantKey !== animateProp) {
991
+ // Compare BOTH the variant key and the resolved keyframes JSON.
992
+ // For static variants the JSON is constant per key; for
993
+ // function-form variants the JSON changes when `custom`
994
+ // changes, which we must treat as a new animation target.
995
+ const resolvedJson = resolvedAnimate ? JSON.stringify(resolvedAnimate) : undefined
996
+ if (lastRanVariantKey !== animateProp || lastRanResolvedJson !== resolvedJson) {
920
997
  lastRanVariantKey = animateProp
998
+ lastRanResolvedJson = resolvedJson
921
999
  runAnimation()
922
1000
  }
923
1001
  } else if (animateProp) {
@@ -956,8 +1034,10 @@
956
1034
  mountedWithInitialFalse = false
957
1035
  }
958
1036
  if (typeof currentAnimateKey === 'string') {
959
- if (lastRanVariantKey !== currentAnimateKey) {
1037
+ const resolvedJson = resolvedAnimate ? JSON.stringify(resolvedAnimate) : undefined
1038
+ if (lastRanVariantKey !== currentAnimateKey || lastRanResolvedJson !== resolvedJson) {
960
1039
  lastRanVariantKey = currentAnimateKey
1040
+ lastRanResolvedJson = resolvedJson
961
1041
  runAnimation()
962
1042
  }
963
1043
  } else {
@@ -989,6 +1069,9 @@
989
1069
  mountedWithInitialFalse = true
990
1070
  if (typeof currentAnimateKey === 'string') {
991
1071
  lastRanVariantKey = currentAnimateKey
1072
+ lastRanResolvedJson = resolvedAnimate
1073
+ ? JSON.stringify(resolvedAnimate)
1074
+ : undefined
992
1075
  }
993
1076
  dataPath = 5
994
1077
  isLoaded = 'ready'
@@ -1081,6 +1164,9 @@
1081
1164
  snapshot = transformSVGPathProperties(element!, snapshot)
1082
1165
  animate(element!, snapshot as DOMKeyframesDefinition, { duration: 0 })
1083
1166
  lastRanVariantKey = currentAnimateKey
1167
+ lastRanResolvedJson = resolvedAnimate
1168
+ ? JSON.stringify(resolvedAnimate)
1169
+ : undefined
1084
1170
  } else {
1085
1171
  runAnimation()
1086
1172
  }
package/dist/types.d.ts CHANGED
@@ -1,8 +1,32 @@
1
1
  import type { AnimationOptions, DOMKeyframesDefinition } from 'motion';
2
2
  import type { Snippet } from 'svelte';
3
+ /**
4
+ * A variant value: either a static keyframes object, or a factory function
5
+ * that receives the consumer-provided `custom` value and returns keyframes.
6
+ *
7
+ * Dynamic (function-form) variants let a single variants object emit
8
+ * per-instance keyframes — common for staggered lists where each child
9
+ * needs its own offset or delay.
10
+ *
11
+ * @example
12
+ * ```svelte
13
+ * <motion.div
14
+ * custom={index}
15
+ * variants={{
16
+ * visible: (i) => ({ opacity: 1, x: i * 50 }),
17
+ * hidden: { opacity: 0 }
18
+ * }}
19
+ * animate="visible"
20
+ * />
21
+ * ```
22
+ */
23
+ export type Variant = DOMKeyframesDefinition | ((custom: unknown) => DOMKeyframesDefinition) | undefined;
3
24
  /**
4
25
  * Variants define named animation states that can be referenced by string keys.
5
26
  *
27
+ * Each entry can be a static keyframes object or a `(custom) => keyframes`
28
+ * factory function (see {@link Variant}).
29
+ *
6
30
  * @example
7
31
  * ```svelte
8
32
  * <script>
@@ -15,7 +39,7 @@ import type { Snippet } from 'svelte';
15
39
  * <motion.div variants={variants} animate="open" />
16
40
  * ```
17
41
  */
18
- export type Variants = Record<string, DOMKeyframesDefinition | undefined>;
42
+ export type Variants = Record<string, Variant>;
19
43
  /**
20
44
  * Initial animation properties for a motion component.
21
45
  *
@@ -245,6 +269,12 @@ export type MotionProps = {
245
269
  key?: string;
246
270
  /** Variants define named animation states */
247
271
  variants?: Variants;
272
+ /**
273
+ * Value passed into function-form variants. Children without their own
274
+ * `custom` prop inherit this from the nearest motion ancestor — matching
275
+ * framer-motion's variant-tree custom propagation.
276
+ */
277
+ custom?: unknown;
248
278
  /** Initial state of the animation (object or variant key) */
249
279
  initial?: MotionInitial;
250
280
  /** Target state of the animation (object or variant key) */
@@ -305,6 +335,22 @@ export type MotionProps = {
305
335
  layout?: boolean | 'position';
306
336
  /** Shared layout animation identifier. Elements with matching layoutId animate between positions. */
307
337
  layoutId?: string;
338
+ /**
339
+ * Mark this element as a scroll container so descendant `layout` animations
340
+ * measure rects in this container's coordinate space. Without it, scrolling
341
+ * mid-animation makes the FLIP transform fight the scroll and the layout
342
+ * animation drifts.
343
+ *
344
+ * Apply on the same element as `overflow: scroll` / `overflow: auto`.
345
+ *
346
+ * @example
347
+ * ```svelte
348
+ * <motion.div layoutScroll style="overflow: auto">
349
+ * <motion.div layout />
350
+ * </motion.div>
351
+ * ```
352
+ */
353
+ layoutScroll?: boolean;
308
354
  /** Ref to the element */
309
355
  ref?: HTMLElement | null;
310
356
  /** Enable drag gestures. true for both axes, or lock to 'x'/'y'. */
@@ -5,10 +5,32 @@ import { type AnimationOptions } from 'motion';
5
5
  * Temporarily clears `transform` to avoid skewing measurements, restoring it
6
6
  * immediately after reading the rect.
7
7
  *
8
+ * When `scrollContainers` are provided, the returned rect is shifted by the
9
+ * **sum** of each container's `scrollLeft` / `scrollTop`. FLIP deltas
10
+ * computed from two such measures stay correct even when the user scrolls
11
+ * any of the containers between measurements — including a nested
12
+ * `layoutScroll` inside another `layoutScroll`. Mirrors framer-motion's
13
+ * `removeElementScroll`, which walks every ancestor in the path.
14
+ *
15
+ * Pass an empty array (or omit) for viewport-relative behaviour.
16
+ *
8
17
  * @param el Element to measure.
9
- * @return DOMRect snapshot of the element.
18
+ * @param scrollContainers Optional ancestor chain with `layoutScroll` enabled.
19
+ * @returns DOMRect snapshot of the element.
20
+ *
21
+ * @example
22
+ * ```ts
23
+ * // No scroll containers — viewport-relative rect.
24
+ * const rect = measureRect(node)
25
+ *
26
+ * // Single ancestor scroll container (one `layoutScroll`).
27
+ * const rect = measureRect(node, [scrollPanel])
28
+ *
29
+ * // Nested `layoutScroll` ancestors — sums offsets from every container.
30
+ * const rect = measureRect(node, [innerScroll, outerScroll])
31
+ * ```
10
32
  */
11
- export declare const measureRect: (el: HTMLElement) => DOMRect;
33
+ export declare const measureRect: (el: HTMLElement, scrollContainers?: HTMLElement[]) => DOMRect;
12
34
  /**
13
35
  * Compute FLIP transform deltas between two rects.
14
36
  *
@@ -5,14 +5,49 @@ import { animate } from 'motion';
5
5
  * Temporarily clears `transform` to avoid skewing measurements, restoring it
6
6
  * immediately after reading the rect.
7
7
  *
8
+ * When `scrollContainers` are provided, the returned rect is shifted by the
9
+ * **sum** of each container's `scrollLeft` / `scrollTop`. FLIP deltas
10
+ * computed from two such measures stay correct even when the user scrolls
11
+ * any of the containers between measurements — including a nested
12
+ * `layoutScroll` inside another `layoutScroll`. Mirrors framer-motion's
13
+ * `removeElementScroll`, which walks every ancestor in the path.
14
+ *
15
+ * Pass an empty array (or omit) for viewport-relative behaviour.
16
+ *
8
17
  * @param el Element to measure.
9
- * @return DOMRect snapshot of the element.
18
+ * @param scrollContainers Optional ancestor chain with `layoutScroll` enabled.
19
+ * @returns DOMRect snapshot of the element.
20
+ *
21
+ * @example
22
+ * ```ts
23
+ * // No scroll containers — viewport-relative rect.
24
+ * const rect = measureRect(node)
25
+ *
26
+ * // Single ancestor scroll container (one `layoutScroll`).
27
+ * const rect = measureRect(node, [scrollPanel])
28
+ *
29
+ * // Nested `layoutScroll` ancestors — sums offsets from every container.
30
+ * const rect = measureRect(node, [innerScroll, outerScroll])
31
+ * ```
10
32
  */
11
- export const measureRect = (el) => {
33
+ export const measureRect = (el, scrollContainers) => {
12
34
  const prev = el.style.transform;
13
35
  try {
14
36
  el.style.transform = 'none';
15
- return el.getBoundingClientRect();
37
+ const rect = el.getBoundingClientRect();
38
+ if (!scrollContainers || scrollContainers.length === 0)
39
+ return rect;
40
+ // Re-express the rect in the *combined* scroll-container coordinate
41
+ // space so a subsequent scroll on any of them doesn't show up as
42
+ // movement. DOMRect's left/top are read-only, so allocate a fresh
43
+ // one with the summed offsets applied.
44
+ let offsetLeft = 0;
45
+ let offsetTop = 0;
46
+ for (const container of scrollContainers) {
47
+ offsetLeft += container.scrollLeft;
48
+ offsetTop += container.scrollTop;
49
+ }
50
+ return new DOMRect(rect.left + offsetLeft, rect.top + offsetTop, rect.width, rect.height);
16
51
  }
17
52
  finally {
18
53
  el.style.transform = prev;
@@ -3,80 +3,74 @@ import type { DOMKeyframesDefinition } from 'motion';
3
3
  /**
4
4
  * Resolves a variant key to its keyframes definition.
5
5
  *
6
- * Looks up a variant name in the variants object and returns the corresponding
7
- * keyframes. Returns `undefined` if the variants object or key is not provided.
6
+ * Looks up `key` in `variants`. When the entry is a function (dynamic
7
+ * variant), it's invoked with `custom` to produce keyframes matching
8
+ * framer-motion's per-instance variant pattern.
8
9
  *
9
10
  * @param variants - The variants object containing named animation states.
10
11
  * @param key - The variant key to look up.
11
- * @returns The keyframes definition for the variant, or undefined if not found.
12
+ * @param custom - Value forwarded to function-form variants. Pass-through
13
+ * `undefined` when no `custom` is in scope; the dynamic variant itself
14
+ * decides how to handle the absent input.
15
+ * @returns The keyframes definition for the variant, or `undefined` if the
16
+ * key is missing.
12
17
  *
13
18
  * @example
14
- * ```typescript
19
+ * ```ts
15
20
  * const variants = {
16
- * visible: { opacity: 1 },
17
- * hidden: { opacity: 0 }
21
+ * visible: (i: number) => ({ x: i * 50 }),
22
+ * hidden: { opacity: 0 }
18
23
  * }
19
- * resolveVariant(variants, 'visible') // { opacity: 1 }
20
- * resolveVariant(variants, 'foo') // undefined
21
- * resolveVariant(undefined, 'visible') // undefined
24
+ * resolveVariant(variants, 'visible', 3) // { x: 150 }
25
+ * resolveVariant(variants, 'hidden') // { opacity: 0 }
26
+ * resolveVariant(undefined, 'visible') // undefined
22
27
  * ```
23
28
  */
24
- export declare const resolveVariant: (variants: Variants | undefined, key: string | undefined) => DOMKeyframesDefinition | undefined;
29
+ export declare const resolveVariant: (variants: Variants | undefined, key: string | undefined, custom?: unknown) => DOMKeyframesDefinition | undefined;
25
30
  /**
26
31
  * Resolves the initial prop to keyframes, handling variant keys and `initial={false}`.
27
32
  *
28
- * When `initial` is a string, looks it up in the variants object. When `initial={false}`,
29
- * returns `false` to skip initial animation. Otherwise returns the keyframes directly.
33
+ * When `initial` is a string, looks it up in the variants object (invoking
34
+ * dynamic variants with `custom`). When `initial={false}`, returns `false` to
35
+ * skip the initial animation. Otherwise returns the keyframes directly.
30
36
  *
31
37
  * @param initial - The initial prop value (keyframes, variant key, false, or undefined).
32
38
  * @param variants - The variants object for resolving string keys.
39
+ * @param custom - Forwarded to function-form variants.
33
40
  * @returns Keyframes definition, `false` to skip animation, or undefined.
34
41
  *
35
42
  * @example
36
- * ```typescript
37
- * const variants = { hidden: { opacity: 0 } }
38
- * resolveInitial('hidden', variants) // { opacity: 0 }
39
- * resolveInitial({ x: 0 }, variants) // { x: 0 }
40
- * resolveInitial(false, variants) // false
41
- * resolveInitial(undefined, variants) // undefined
43
+ * ```ts
44
+ * const variants = { hidden: (i: number) => ({ x: -i * 100 }) }
45
+ * resolveInitial('hidden', variants, 2) // { x: -200 }
46
+ * resolveInitial({ x: 0 }, variants) // { x: 0 }
47
+ * resolveInitial(false, variants) // false
48
+ * resolveInitial(undefined, variants) // undefined
42
49
  * ```
43
50
  */
44
- export declare const resolveInitial: (initial: MotionInitial, variants: Variants | undefined) => DOMKeyframesDefinition | false | undefined;
51
+ export declare const resolveInitial: (initial: MotionInitial, variants: Variants | undefined, custom?: unknown) => DOMKeyframesDefinition | false | undefined;
45
52
  /**
46
53
  * Resolves the animate prop to keyframes, handling variant keys.
47
54
  *
48
- * When `animate` is a string, looks it up in the variants object.
49
- * Otherwise returns the keyframes directly.
55
+ * When `animate` is a string, looks it up in the variants object (invoking
56
+ * dynamic variants with `custom`). Otherwise returns the keyframes directly.
50
57
  *
51
58
  * @param animate - The animate prop value (keyframes, variant key, or undefined).
52
59
  * @param variants - The variants object for resolving string keys.
60
+ * @param custom - Forwarded to function-form variants.
53
61
  * @returns Keyframes definition or undefined.
54
- *
55
- * @example
56
- * ```typescript
57
- * const variants = { visible: { opacity: 1 } }
58
- * resolveAnimate('visible', variants) // { opacity: 1 }
59
- * resolveAnimate({ scale: 1.2 }, variants) // { scale: 1.2 }
60
- * resolveAnimate(undefined, variants) // undefined
61
- * ```
62
62
  */
63
- export declare const resolveAnimate: (animate: MotionAnimate, variants: Variants | undefined) => DOMKeyframesDefinition | undefined;
63
+ export declare const resolveAnimate: (animate: MotionAnimate, variants: Variants | undefined, custom?: unknown) => DOMKeyframesDefinition | undefined;
64
64
  /**
65
65
  * Resolves the exit prop to keyframes, handling variant keys.
66
66
  *
67
- * When `exit` is a string, looks it up in the variants object.
68
- * Otherwise returns the keyframes directly. Used by AnimatePresence for exit animations.
67
+ * When `exit` is a string, looks it up in the variants object (invoking
68
+ * dynamic variants with `custom`). Otherwise returns the keyframes directly.
69
+ * Used by AnimatePresence for exit animations.
69
70
  *
70
71
  * @param exit - The exit prop value (keyframes, variant key, or undefined).
71
72
  * @param variants - The variants object for resolving string keys.
73
+ * @param custom - Forwarded to function-form variants.
72
74
  * @returns Keyframes definition or undefined.
73
- *
74
- * @example
75
- * ```typescript
76
- * const variants = { hidden: { opacity: 0 } }
77
- * resolveExit('hidden', variants) // { opacity: 0 }
78
- * resolveExit({ y: -100 }, variants) // { y: -100 }
79
- * resolveExit(undefined, variants) // undefined
80
- * ```
81
75
  */
82
- export declare const resolveExit: (exit: MotionExit, variants: Variants | undefined) => DOMKeyframesDefinition | undefined;
76
+ export declare const resolveExit: (exit: MotionExit, variants: Variants | undefined, custom?: unknown) => DOMKeyframesDefinition | undefined;
@@ -1,104 +1,101 @@
1
1
  /**
2
2
  * Resolves a variant key to its keyframes definition.
3
3
  *
4
- * Looks up a variant name in the variants object and returns the corresponding
5
- * keyframes. Returns `undefined` if the variants object or key is not provided.
4
+ * Looks up `key` in `variants`. When the entry is a function (dynamic
5
+ * variant), it's invoked with `custom` to produce keyframes matching
6
+ * framer-motion's per-instance variant pattern.
6
7
  *
7
8
  * @param variants - The variants object containing named animation states.
8
9
  * @param key - The variant key to look up.
9
- * @returns The keyframes definition for the variant, or undefined if not found.
10
+ * @param custom - Value forwarded to function-form variants. Pass-through
11
+ * `undefined` when no `custom` is in scope; the dynamic variant itself
12
+ * decides how to handle the absent input.
13
+ * @returns The keyframes definition for the variant, or `undefined` if the
14
+ * key is missing.
10
15
  *
11
16
  * @example
12
- * ```typescript
17
+ * ```ts
13
18
  * const variants = {
14
- * visible: { opacity: 1 },
15
- * hidden: { opacity: 0 }
19
+ * visible: (i: number) => ({ x: i * 50 }),
20
+ * hidden: { opacity: 0 }
16
21
  * }
17
- * resolveVariant(variants, 'visible') // { opacity: 1 }
18
- * resolveVariant(variants, 'foo') // undefined
19
- * resolveVariant(undefined, 'visible') // undefined
22
+ * resolveVariant(variants, 'visible', 3) // { x: 150 }
23
+ * resolveVariant(variants, 'hidden') // { opacity: 0 }
24
+ * resolveVariant(undefined, 'visible') // undefined
20
25
  * ```
21
26
  */
22
- export const resolveVariant = (variants, key) => {
27
+ export const resolveVariant = (variants, key, custom) => {
23
28
  if (!variants || !key)
24
29
  return undefined;
25
- return variants[key];
30
+ const entry = variants[key];
31
+ if (typeof entry === 'function')
32
+ return entry(custom);
33
+ return entry;
26
34
  };
27
35
  /**
28
36
  * Resolves the initial prop to keyframes, handling variant keys and `initial={false}`.
29
37
  *
30
- * When `initial` is a string, looks it up in the variants object. When `initial={false}`,
31
- * returns `false` to skip initial animation. Otherwise returns the keyframes directly.
38
+ * When `initial` is a string, looks it up in the variants object (invoking
39
+ * dynamic variants with `custom`). When `initial={false}`, returns `false` to
40
+ * skip the initial animation. Otherwise returns the keyframes directly.
32
41
  *
33
42
  * @param initial - The initial prop value (keyframes, variant key, false, or undefined).
34
43
  * @param variants - The variants object for resolving string keys.
44
+ * @param custom - Forwarded to function-form variants.
35
45
  * @returns Keyframes definition, `false` to skip animation, or undefined.
36
46
  *
37
47
  * @example
38
- * ```typescript
39
- * const variants = { hidden: { opacity: 0 } }
40
- * resolveInitial('hidden', variants) // { opacity: 0 }
41
- * resolveInitial({ x: 0 }, variants) // { x: 0 }
42
- * resolveInitial(false, variants) // false
43
- * resolveInitial(undefined, variants) // undefined
48
+ * ```ts
49
+ * const variants = { hidden: (i: number) => ({ x: -i * 100 }) }
50
+ * resolveInitial('hidden', variants, 2) // { x: -200 }
51
+ * resolveInitial({ x: 0 }, variants) // { x: 0 }
52
+ * resolveInitial(false, variants) // false
53
+ * resolveInitial(undefined, variants) // undefined
44
54
  * ```
45
55
  */
46
- export const resolveInitial = (initial, variants) => {
56
+ export const resolveInitial = (initial, variants, custom) => {
47
57
  if (initial === false)
48
58
  return false;
49
59
  if (initial === undefined)
50
60
  return undefined;
51
61
  if (typeof initial === 'string')
52
- return resolveVariant(variants, initial);
62
+ return resolveVariant(variants, initial, custom);
53
63
  return initial;
54
64
  };
55
65
  /**
56
66
  * Resolves the animate prop to keyframes, handling variant keys.
57
67
  *
58
- * When `animate` is a string, looks it up in the variants object.
59
- * Otherwise returns the keyframes directly.
68
+ * When `animate` is a string, looks it up in the variants object (invoking
69
+ * dynamic variants with `custom`). Otherwise returns the keyframes directly.
60
70
  *
61
71
  * @param animate - The animate prop value (keyframes, variant key, or undefined).
62
72
  * @param variants - The variants object for resolving string keys.
73
+ * @param custom - Forwarded to function-form variants.
63
74
  * @returns Keyframes definition or undefined.
64
- *
65
- * @example
66
- * ```typescript
67
- * const variants = { visible: { opacity: 1 } }
68
- * resolveAnimate('visible', variants) // { opacity: 1 }
69
- * resolveAnimate({ scale: 1.2 }, variants) // { scale: 1.2 }
70
- * resolveAnimate(undefined, variants) // undefined
71
- * ```
72
75
  */
73
- export const resolveAnimate = (animate, variants) => {
76
+ export const resolveAnimate = (animate, variants, custom) => {
74
77
  if (animate === undefined)
75
78
  return undefined;
76
79
  if (typeof animate === 'string')
77
- return resolveVariant(variants, animate);
80
+ return resolveVariant(variants, animate, custom);
78
81
  return animate;
79
82
  };
80
83
  /**
81
84
  * Resolves the exit prop to keyframes, handling variant keys.
82
85
  *
83
- * When `exit` is a string, looks it up in the variants object.
84
- * Otherwise returns the keyframes directly. Used by AnimatePresence for exit animations.
86
+ * When `exit` is a string, looks it up in the variants object (invoking
87
+ * dynamic variants with `custom`). Otherwise returns the keyframes directly.
88
+ * Used by AnimatePresence for exit animations.
85
89
  *
86
90
  * @param exit - The exit prop value (keyframes, variant key, or undefined).
87
91
  * @param variants - The variants object for resolving string keys.
92
+ * @param custom - Forwarded to function-form variants.
88
93
  * @returns Keyframes definition or undefined.
89
- *
90
- * @example
91
- * ```typescript
92
- * const variants = { hidden: { opacity: 0 } }
93
- * resolveExit('hidden', variants) // { opacity: 0 }
94
- * resolveExit({ y: -100 }, variants) // { y: -100 }
95
- * resolveExit(undefined, variants) // undefined
96
- * ```
97
94
  */
98
- export const resolveExit = (exit, variants) => {
95
+ export const resolveExit = (exit, variants, custom) => {
99
96
  if (exit === undefined)
100
97
  return undefined;
101
98
  if (typeof exit === 'string')
102
- return resolveVariant(variants, exit);
99
+ return resolveVariant(variants, exit, custom);
103
100
  return exit;
104
101
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanspeak/svelte-motion",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
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",