@humanspeak/svelte-motion 0.3.2 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -9,6 +9,8 @@ export { useAnimationFrame } from './utils/animationFrame';
9
9
  export { useCycle } from './utils/cycle';
10
10
  export type { Cycle, CycleState } from './utils/cycle';
11
11
  export { createDragControls } from './utils/dragControls';
12
+ export { useInView } from './utils/inView';
13
+ export type { UseInViewOptions } from './utils/inView';
12
14
  export { useMotionTemplate } from './utils/motionTemplate';
13
15
  export { useMotionValue } from './utils/motionValue';
14
16
  export type { MotionValue } from './utils/motionValue';
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ export { clamp, distance, distance2D, interpolate, mix, pipe, progress, wrap } f
10
10
  export { useAnimationFrame } from './utils/animationFrame';
11
11
  export { useCycle } from './utils/cycle';
12
12
  export { createDragControls } from './utils/dragControls';
13
+ export { useInView } from './utils/inView';
13
14
  export { useMotionTemplate } from './utils/motionTemplate';
14
15
  export { useMotionValue } from './utils/motionValue';
15
16
  export { useMotionValueEvent } from './utils/motionValueEvent';
@@ -0,0 +1,74 @@
1
+ import { type ElementOrGetter } from './dom.js';
2
+ /**
3
+ * Reference set of DOM `ElementOrGetter` inputs keyed by name.
4
+ *
5
+ * Each value is either a resolved element, a getter that returns one (used
6
+ * with Svelte's `bind:this` post-mount timing), or `undefined` when the
7
+ * caller does not need this slot.
8
+ */
9
+ export type AttachableRefs = Record<string, ElementOrGetter | undefined>;
10
+ /**
11
+ * Map of resolved elements supplied to `onAttach`. Slots whose ref was
12
+ * `undefined` resolve to `undefined`.
13
+ */
14
+ export type ResolvedRefs<R extends AttachableRefs> = {
15
+ [K in keyof R]: HTMLElement | undefined;
16
+ };
17
+ export type AttachableConfig<R extends AttachableRefs> = {
18
+ /** DOM refs that must resolve before `onAttach` runs. */
19
+ refs: R;
20
+ /**
21
+ * Called when every supplied ref has resolved. Receives a `stop` function
22
+ * that synchronously tears down the attachment - useful for one-shot
23
+ * latches that need to detach inside their own callback.
24
+ *
25
+ * Return a cleanup function for the standard "tear down on last
26
+ * unsubscribe" path.
27
+ */
28
+ onAttach: (els: ResolvedRefs<R>, stop: VoidFunction) => VoidFunction | void;
29
+ /**
30
+ * When this returns `true`, `subscribe` short-circuits without attaching.
31
+ * Used by hooks that latch a value and stop observing (e.g. `once`).
32
+ * Subscribers added after the latch increment the refcount but never
33
+ * trigger `onAttach`; their unsubscribe is a clean no-op.
34
+ */
35
+ isLatched?: () => boolean;
36
+ };
37
+ export type Attachable = {
38
+ /**
39
+ * Register a subscriber. Triggers `onAttach` on the first subscriber,
40
+ * polls on `requestAnimationFrame` until refs resolve. Returns a release
41
+ * function that cleans up when the last subscriber leaves.
42
+ *
43
+ * Callers must eventually unsubscribe; the rAF poll loop continues until
44
+ * either every ref resolves or the last subscriber releases.
45
+ */
46
+ subscribe: () => () => void;
47
+ };
48
+ /**
49
+ * Builds a subscriber-refcounted DOM-attachment primitive. Both `useScroll`
50
+ * and `useInView` use this to defer observer setup until a subscriber arrives,
51
+ * poll for `bind:this` element resolution, and tear down on the last
52
+ * unsubscribe.
53
+ *
54
+ * @param config Attachment configuration: refs to resolve, an `onAttach`
55
+ * callback that returns a cleanup function, and an optional `isLatched`
56
+ * short-circuit.
57
+ * @returns An `Attachable` exposing `subscribe()` for use inside Svelte
58
+ * `readable(..., start)` callbacks.
59
+ * @example
60
+ * ```ts
61
+ * const attachable = createAttachable({
62
+ * refs: { target },
63
+ * onAttach: ({ target }) => {
64
+ * const stopObserving = startObserver(target!, ...)
65
+ * return stopObserving
66
+ * }
67
+ * })
68
+ * return readable(initial, (set) => {
69
+ * const release = attachable.subscribe()
70
+ * return release
71
+ * })
72
+ * ```
73
+ */
74
+ export declare const createAttachable: <R extends AttachableRefs>(config: AttachableConfig<R>) => Attachable;
@@ -0,0 +1,104 @@
1
+ import { resolveElement } from './dom.js';
2
+ /**
3
+ * Builds a subscriber-refcounted DOM-attachment primitive. Both `useScroll`
4
+ * and `useInView` use this to defer observer setup until a subscriber arrives,
5
+ * poll for `bind:this` element resolution, and tear down on the last
6
+ * unsubscribe.
7
+ *
8
+ * @param config Attachment configuration: refs to resolve, an `onAttach`
9
+ * callback that returns a cleanup function, and an optional `isLatched`
10
+ * short-circuit.
11
+ * @returns An `Attachable` exposing `subscribe()` for use inside Svelte
12
+ * `readable(..., start)` callbacks.
13
+ * @example
14
+ * ```ts
15
+ * const attachable = createAttachable({
16
+ * refs: { target },
17
+ * onAttach: ({ target }) => {
18
+ * const stopObserving = startObserver(target!, ...)
19
+ * return stopObserving
20
+ * }
21
+ * })
22
+ * return readable(initial, (set) => {
23
+ * const release = attachable.subscribe()
24
+ * return release
25
+ * })
26
+ * ```
27
+ */
28
+ export const createAttachable = (config) => {
29
+ let cleanup;
30
+ let pollRaf = 0;
31
+ let subscriberCount = 0;
32
+ // When stop() runs synchronously inside onAttach, cleanup hasn't been
33
+ // assigned yet. Defer the teardown so the just-returned disposer still
34
+ // gets invoked.
35
+ let attaching = false;
36
+ let stopRequestedDuringAttach = false;
37
+ const cancelPoll = () => {
38
+ if (pollRaf) {
39
+ cancelAnimationFrame(pollRaf);
40
+ pollRaf = 0;
41
+ }
42
+ };
43
+ const stop = () => {
44
+ cancelPoll();
45
+ if (attaching) {
46
+ stopRequestedDuringAttach = true;
47
+ return;
48
+ }
49
+ if (cleanup) {
50
+ const fn = cleanup;
51
+ cleanup = undefined;
52
+ fn();
53
+ }
54
+ };
55
+ const tryAttach = () => {
56
+ if (cleanup || config.isLatched?.())
57
+ return;
58
+ const els = {};
59
+ let needsPoll = false;
60
+ for (const key of Object.keys(config.refs)) {
61
+ const ref = config.refs[key];
62
+ const el = resolveElement(ref);
63
+ if (ref && !el)
64
+ needsPoll = true;
65
+ els[key] = el;
66
+ }
67
+ if (needsPoll) {
68
+ if (!pollRaf) {
69
+ pollRaf = requestAnimationFrame(() => {
70
+ pollRaf = 0;
71
+ tryAttach();
72
+ });
73
+ }
74
+ return;
75
+ }
76
+ attaching = true;
77
+ let result;
78
+ try {
79
+ result = config.onAttach(els, stop);
80
+ }
81
+ finally {
82
+ attaching = false;
83
+ }
84
+ if (typeof result === 'function')
85
+ cleanup = result;
86
+ if (stopRequestedDuringAttach) {
87
+ stopRequestedDuringAttach = false;
88
+ stop();
89
+ }
90
+ };
91
+ return {
92
+ subscribe: () => {
93
+ subscriberCount++;
94
+ tryAttach();
95
+ return () => {
96
+ if (subscriberCount > 0)
97
+ subscriberCount--;
98
+ if (subscriberCount > 0)
99
+ return;
100
+ stop();
101
+ };
102
+ }
103
+ };
104
+ };
@@ -14,3 +14,30 @@
14
14
  * @return Whether the value is a DOM `Element`.
15
15
  */
16
16
  export declare const isDomElement: (v: unknown) => v is Element;
17
+ /**
18
+ * An element reference - either an element directly or a getter function
19
+ * that returns one. Getters defer resolution past mount, which is useful
20
+ * with Svelte's `bind:this` where the element isn't available synchronously.
21
+ *
22
+ * Both `null` and `undefined` returns from the getter are normalised to
23
+ * `undefined` by `resolveElement`, so either nullable shape works.
24
+ */
25
+ export type ElementOrGetter = HTMLElement | (() => HTMLElement | null | undefined);
26
+ /**
27
+ * Resolves an `ElementOrGetter` to an `HTMLElement`, or `undefined` if not
28
+ * yet available (e.g. a getter is supplied but the bound element hasn't
29
+ * mounted).
30
+ *
31
+ * Coerces a `null` getter result to `undefined` so the common
32
+ * `let el: HTMLElement | null = null` `bind:this` pattern lines up with the
33
+ * declared return type.
34
+ *
35
+ * @param ref Element or getter to resolve.
36
+ * @returns The resolved element, or `undefined` when not yet available.
37
+ * @example
38
+ * ```ts
39
+ * let node: HTMLElement | null = null
40
+ * resolveElement(() => node) // => undefined until bind:this fires
41
+ * ```
42
+ */
43
+ export declare const resolveElement: (ref?: ElementOrGetter) => HTMLElement | undefined;
package/dist/utils/dom.js CHANGED
@@ -16,3 +16,26 @@
16
16
  export const isDomElement = (v) => {
17
17
  return !!v && typeof v.getBoundingClientRect === 'function';
18
18
  };
19
+ /**
20
+ * Resolves an `ElementOrGetter` to an `HTMLElement`, or `undefined` if not
21
+ * yet available (e.g. a getter is supplied but the bound element hasn't
22
+ * mounted).
23
+ *
24
+ * Coerces a `null` getter result to `undefined` so the common
25
+ * `let el: HTMLElement | null = null` `bind:this` pattern lines up with the
26
+ * declared return type.
27
+ *
28
+ * @param ref Element or getter to resolve.
29
+ * @returns The resolved element, or `undefined` when not yet available.
30
+ * @example
31
+ * ```ts
32
+ * let node: HTMLElement | null = null
33
+ * resolveElement(() => node) // => undefined until bind:this fires
34
+ * ```
35
+ */
36
+ export const resolveElement = (ref) => {
37
+ if (!ref)
38
+ return undefined;
39
+ const value = typeof ref === 'function' ? ref() : ref;
40
+ return value ?? undefined;
41
+ };
@@ -1,4 +1,6 @@
1
1
  import { type AnimationOptions, type DOMKeyframesDefinition } from 'motion';
2
+ import { type Readable } from 'svelte/store';
3
+ import { type ElementOrGetter } from './dom.js';
2
4
  /**
3
5
  * Split a whileInView definition into keyframes and an optional nested transition.
4
6
  *
@@ -41,21 +43,21 @@ export declare const computeInViewBaseline: (el: HTMLElement, opts: {
41
43
  whileInView?: Record<string, unknown>;
42
44
  }) => Record<string, unknown>;
43
45
  /**
44
- * Attach whileInView interactions to an element using IntersectionObserver.
46
+ * Attach whileInView interactions to an element via motion's `inView` primitive.
45
47
  *
46
- * On intersection (element enters viewport), animates to `whileInView` state
47
- * (using nested `transition` if provided). On un-intersection, restores changed
48
- * keys to the baseline using the merged root/component transition.
48
+ * On entry, animates to `whileInView` state (using the nested `transition` if
49
+ * provided). On exit, restores the changed keys to a baseline computed from
50
+ * `initial` / `animate` / neutral transform defaults / inline styles.
49
51
  *
50
- * Critical fix for issue #230: Checks `entry.isIntersecting` immediately on
51
- * first callback to handle elements already in viewport on mount.
52
+ * Delegates to motion's `inView` so the IntersectionObserver implementation
53
+ * is shared with the public `useInView` hook.
52
54
  *
53
55
  * @param el Target element.
54
56
  * @param whileInView While-in-view definition.
55
57
  * @param mergedTransition Root/component merged transition.
56
58
  * @param callbacks Optional lifecycle callbacks for in-view start/end and animation completion.
57
59
  * @param baselineSources Optional sources used to compute baseline.
58
- * @returns Cleanup function to disconnect the IntersectionObserver.
60
+ * @returns Cleanup function to stop observing.
59
61
  * @example
60
62
  * const cleanup = attachWhileInView(
61
63
  * element,
@@ -67,7 +69,7 @@ export declare const computeInViewBaseline: (el: HTMLElement, opts: {
67
69
  * },
68
70
  * { initial: { opacity: 0, y: 50 } }
69
71
  * )
70
- * // Later: cleanup() to disconnect observer
72
+ * // Later: cleanup() to stop observing
71
73
  */
72
74
  export declare const attachWhileInView: (el: HTMLElement, whileInView: Record<string, unknown> | undefined, mergedTransition: AnimationOptions, callbacks?: {
73
75
  onStart?: () => void;
@@ -77,3 +79,55 @@ export declare const attachWhileInView: (el: HTMLElement, whileInView: Record<st
77
79
  initial?: Record<string, unknown>;
78
80
  animate?: Record<string, unknown>;
79
81
  }) => (() => void);
82
+ /**
83
+ * Options accepted by `useInView`.
84
+ */
85
+ export type UseInViewOptions = {
86
+ /** Element to use as the IntersectionObserver root. Defaults to the viewport. */
87
+ root?: ElementOrGetter;
88
+ /** CSS margin string applied to the root bounding box (e.g. `"100px 0px"`). */
89
+ margin?: string;
90
+ /** Fraction (0-1) or `"some"` / `"all"` of the target that must be visible. */
91
+ amount?: 'some' | 'all' | number;
92
+ /** When `true`, the store latches to `true` on first entry and never flips back. */
93
+ once?: boolean;
94
+ /** Initial value emitted before the first IntersectionObserver callback. */
95
+ initial?: boolean;
96
+ };
97
+ /**
98
+ * Returns a Svelte readable store that tracks whether `target` is in the
99
+ * viewport. Mirrors Framer Motion's `useInView` so the same options
100
+ * (`root`, `margin`, `amount`, `once`, `initial`) work as in React.
101
+ *
102
+ * `target` (and `options.root`) accept either an `HTMLElement` directly or a
103
+ * getter `() => HTMLElement | undefined`. With Svelte's `bind:this`, the
104
+ * element isn't available until after mount, so element resolution is
105
+ * deferred until the first subscriber arrives; if the element isn't ready,
106
+ * the hook polls on `requestAnimationFrame` until it is.
107
+ *
108
+ * SSR-safe: returns a static `readable(initial)` when `window` or
109
+ * `IntersectionObserver` is unavailable.
110
+ *
111
+ * @param target - Element (or getter) to observe.
112
+ * @param options - Optional `UseInViewOptions` (`root`, `margin`, `amount`,
113
+ * `once`, `initial`).
114
+ * @returns A `Readable<boolean>` that flips to `true` while `target` is in view.
115
+ * @see https://motion.dev/docs/react-use-in-view
116
+ *
117
+ * @example
118
+ * ```svelte
119
+ * <script>
120
+ * import { useInView } from '@humanspeak/svelte-motion'
121
+ *
122
+ * let ref
123
+ * const inView = useInView(() => ref, { once: true })
124
+ *
125
+ * $effect(() => {
126
+ * if ($inView) trackImpression()
127
+ * })
128
+ * </script>
129
+ *
130
+ * <div bind:this={ref}>{$inView ? 'visible' : 'hidden'}</div>
131
+ * ```
132
+ */
133
+ export declare const useInView: (target: ElementOrGetter, options?: UseInViewOptions) => Readable<boolean>;
@@ -1,4 +1,30 @@
1
- import { animate } from 'motion';
1
+ import { animate, inView as motionInView } from 'motion';
2
+ import { readable, writable } from 'svelte/store';
3
+ import { createAttachable } from './attachable.js';
4
+ import {} from './dom.js';
5
+ const CSS_FUNCTION_RE = /\b(var|calc|min|max|clamp|rgb|rgba|hsl|hsla|url)\s*\(/i;
6
+ /**
7
+ * Read an inline CSS-function value (var/calc/url/etc.) for `propName`
8
+ * directly from the element's style declaration. Returns `null` for missing
9
+ * or non-function values so the caller can fall back to `getComputedStyle`.
10
+ *
11
+ * Uses `style.getPropertyValue` so values like `url(data:image/svg+xml;...)`
12
+ * with nested semicolons are preserved intact - the browser has already
13
+ * parsed the declaration, no string scraping required.
14
+ *
15
+ * @param el Element whose inline style is read.
16
+ * @param propName Camel-case JS property name (e.g. `borderColor`).
17
+ * @returns The inline CSS function value, or `null`.
18
+ * @example
19
+ * getInlineCssFunction(node, 'background') // => 'var(--brand)' | null
20
+ */
21
+ const getInlineCssFunction = (el, propName) => {
22
+ const kebab = propName.replace(/([A-Z])/g, '-$1').toLowerCase();
23
+ const value = el.style.getPropertyValue(kebab).trim();
24
+ if (!value)
25
+ return null;
26
+ return CSS_FUNCTION_RE.test(value) ? value : null;
27
+ };
2
28
  /**
3
29
  * Split a whileInView definition into keyframes and an optional nested transition.
4
30
  *
@@ -59,27 +85,6 @@ export const computeInViewBaseline = (el, opts) => {
59
85
  opacity: 1
60
86
  };
61
87
  const cs = getComputedStyle(el);
62
- const inlineStyle = el.getAttribute('style') || '';
63
- // Helper to escape regex metacharacters to prevent ReDoS and ensure literal matching
64
- const escapeRegExp = (str) => {
65
- return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
66
- };
67
- // Helper to extract CSS function (var, calc, min, max, etc.) from inline style if present
68
- const getInlineStyleValue = (propName) => {
69
- const kebabCase = propName.replace(/([A-Z])/g, '-$1').toLowerCase();
70
- const escapedKebabCase = escapeRegExp(kebabCase);
71
- // Match property name at start of string or after semicolon
72
- const regex = new RegExp(`(?:^|;)\\s*${escapedKebabCase}\\s*:\\s*([^;]+)`, 'i');
73
- const match = inlineStyle.match(regex);
74
- if (match) {
75
- const value = match[1].trim();
76
- // Preserve CSS functions: var(), calc(), min(), max(), clamp(), rgb(), hsl(), url(), etc.
77
- if (/\b(var|calc|min|max|clamp|rgb|rgba|hsl|hsla|url)\s*\(/.test(value)) {
78
- return value;
79
- }
80
- }
81
- return null;
82
- };
83
88
  for (const key of Object.keys(whileInViewRecord)) {
84
89
  if (Object.prototype.hasOwnProperty.call(animateRecord, key)) {
85
90
  baseline[key] = animateRecord[key];
@@ -91,8 +96,7 @@ export const computeInViewBaseline = (el, opts) => {
91
96
  baseline[key] = neutralTransformDefaults[key];
92
97
  }
93
98
  else {
94
- // Check if inline style has a CSS variable for this property
95
- const inlineValue = getInlineStyleValue(key);
99
+ const inlineValue = getInlineCssFunction(el, key);
96
100
  if (inlineValue) {
97
101
  baseline[key] = inlineValue;
98
102
  }
@@ -104,21 +108,21 @@ export const computeInViewBaseline = (el, opts) => {
104
108
  return baseline;
105
109
  };
106
110
  /**
107
- * Attach whileInView interactions to an element using IntersectionObserver.
111
+ * Attach whileInView interactions to an element via motion's `inView` primitive.
108
112
  *
109
- * On intersection (element enters viewport), animates to `whileInView` state
110
- * (using nested `transition` if provided). On un-intersection, restores changed
111
- * keys to the baseline using the merged root/component transition.
113
+ * On entry, animates to `whileInView` state (using the nested `transition` if
114
+ * provided). On exit, restores the changed keys to a baseline computed from
115
+ * `initial` / `animate` / neutral transform defaults / inline styles.
112
116
  *
113
- * Critical fix for issue #230: Checks `entry.isIntersecting` immediately on
114
- * first callback to handle elements already in viewport on mount.
117
+ * Delegates to motion's `inView` so the IntersectionObserver implementation
118
+ * is shared with the public `useInView` hook.
115
119
  *
116
120
  * @param el Target element.
117
121
  * @param whileInView While-in-view definition.
118
122
  * @param mergedTransition Root/component merged transition.
119
123
  * @param callbacks Optional lifecycle callbacks for in-view start/end and animation completion.
120
124
  * @param baselineSources Optional sources used to compute baseline.
121
- * @returns Cleanup function to disconnect the IntersectionObserver.
125
+ * @returns Cleanup function to stop observing.
122
126
  * @example
123
127
  * const cleanup = attachWhileInView(
124
128
  * element,
@@ -130,50 +134,113 @@ export const computeInViewBaseline = (el, opts) => {
130
134
  * },
131
135
  * { initial: { opacity: 0, y: 50 } }
132
136
  * )
133
- * // Later: cleanup() to disconnect observer
137
+ * // Later: cleanup() to stop observing
134
138
  */
135
139
  export const attachWhileInView = (el, whileInView, mergedTransition, callbacks, baselineSources) => {
136
140
  if (!whileInView)
137
141
  return () => { };
138
- let hasAnimated = false;
139
- let inViewBaseline = null;
140
- const observer = new IntersectionObserver((entries) => {
141
- for (const entry of entries) {
142
- if (entry.isIntersecting && !hasAnimated) {
143
- // Element entered viewport: animate to whileInView state
144
- hasAnimated = true;
145
- inViewBaseline = computeInViewBaseline(el, {
146
- initial: baselineSources?.initial,
147
- animate: baselineSources?.animate,
148
- whileInView
149
- });
150
- callbacks?.onStart?.();
151
- const { keyframes, transition } = splitInViewDefinition(whileInView);
152
- const animation = animate(el, keyframes, (transition ?? mergedTransition));
153
- // Call onAnimationComplete when animation finishes
154
- animation.finished
155
- .then(() => {
156
- callbacks?.onAnimationComplete?.(keyframes);
157
- })
158
- .catch(() => {
159
- // Animation was cancelled, don't call completion callback
160
- });
161
- }
162
- else if (!entry.isIntersecting && hasAnimated) {
163
- // Element left viewport: animate back to baseline
164
- if (inViewBaseline && Object.keys(inViewBaseline).length > 0) {
165
- animate(el, inViewBaseline, mergedTransition);
166
- }
167
- callbacks?.onEnd?.();
168
- hasAnimated = false;
142
+ return motionInView(el, () => {
143
+ const inViewBaseline = computeInViewBaseline(el, {
144
+ initial: baselineSources?.initial,
145
+ animate: baselineSources?.animate,
146
+ whileInView
147
+ });
148
+ callbacks?.onStart?.();
149
+ const { keyframes, transition } = splitInViewDefinition(whileInView);
150
+ const animation = animate(el, keyframes, (transition ?? mergedTransition));
151
+ animation.finished
152
+ .then(() => {
153
+ callbacks?.onAnimationComplete?.(keyframes);
154
+ })
155
+ .catch(() => {
156
+ /* animation cancelled skip completion callback */
157
+ });
158
+ return () => {
159
+ if (Object.keys(inViewBaseline).length > 0) {
160
+ animate(el, inViewBaseline, mergedTransition);
169
161
  }
170
- }
171
- }, { threshold: 0 } // Fire as soon as any part is visible
172
- );
173
- // Start observing - IntersectionObserver fires immediately for already-visible elements
174
- observer.observe(el);
175
- // Return cleanup function
176
- return () => {
177
- observer.disconnect();
162
+ callbacks?.onEnd?.();
163
+ };
164
+ }, { amount: 0 });
165
+ };
166
+ /**
167
+ * Returns a Svelte readable store that tracks whether `target` is in the
168
+ * viewport. Mirrors Framer Motion's `useInView` so the same options
169
+ * (`root`, `margin`, `amount`, `once`, `initial`) work as in React.
170
+ *
171
+ * `target` (and `options.root`) accept either an `HTMLElement` directly or a
172
+ * getter `() => HTMLElement | undefined`. With Svelte's `bind:this`, the
173
+ * element isn't available until after mount, so element resolution is
174
+ * deferred until the first subscriber arrives; if the element isn't ready,
175
+ * the hook polls on `requestAnimationFrame` until it is.
176
+ *
177
+ * SSR-safe: returns a static `readable(initial)` when `window` or
178
+ * `IntersectionObserver` is unavailable.
179
+ *
180
+ * @param target - Element (or getter) to observe.
181
+ * @param options - Optional `UseInViewOptions` (`root`, `margin`, `amount`,
182
+ * `once`, `initial`).
183
+ * @returns A `Readable<boolean>` that flips to `true` while `target` is in view.
184
+ * @see https://motion.dev/docs/react-use-in-view
185
+ *
186
+ * @example
187
+ * ```svelte
188
+ * <script>
189
+ * import { useInView } from '@humanspeak/svelte-motion'
190
+ *
191
+ * let ref
192
+ * const inView = useInView(() => ref, { once: true })
193
+ *
194
+ * $effect(() => {
195
+ * if ($inView) trackImpression()
196
+ * })
197
+ * </script>
198
+ *
199
+ * <div bind:this={ref}>{$inView ? 'visible' : 'hidden'}</div>
200
+ * ```
201
+ */
202
+ export const useInView = (target, options = {}) => {
203
+ const initial = options.initial ?? false;
204
+ if (typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') {
205
+ return readable(initial);
206
+ }
207
+ const store = writable(initial);
208
+ let current = initial;
209
+ const update = (next) => {
210
+ if (next === current)
211
+ return;
212
+ current = next;
213
+ store.set(next);
178
214
  };
215
+ let latched = false;
216
+ const attachable = createAttachable({
217
+ refs: { target, root: options.root },
218
+ isLatched: () => latched,
219
+ onAttach: ({ target: el, root }, stop) => motionInView(el, () => {
220
+ update(true);
221
+ if (options.once) {
222
+ // Detach inside the entry callback; motion's inView
223
+ // handles re-entry safely via observer.unobserve.
224
+ latched = true;
225
+ stop();
226
+ return;
227
+ }
228
+ return () => update(false);
229
+ }, {
230
+ root: root,
231
+ // framer-motion types `margin` as a CSS-shorthand template
232
+ // literal; we expose plain `string` so the public API is
233
+ // ergonomic and forward-compat with future motion changes.
234
+ margin: options.margin,
235
+ amount: options.amount
236
+ })
237
+ });
238
+ return readable(initial, (set) => {
239
+ const release = attachable.subscribe();
240
+ const unsub = store.subscribe(set);
241
+ return () => {
242
+ unsub();
243
+ release();
244
+ };
245
+ });
179
246
  };
@@ -1,15 +1,10 @@
1
1
  import { type Readable } from 'svelte/store';
2
+ import { type ElementOrGetter } from './dom.js';
2
3
  /**
3
4
  * A scroll offset edge defined as a string (e.g. `"start"`, `"end"`, `"center"`)
4
5
  * or a number (0–1 progress). Each offset entry is a pair of `[target, container]`.
5
6
  */
6
7
  type ScrollOffset = Array<[number | string, number | string]> | string[];
7
- /**
8
- * An element reference — either an element directly or a getter function
9
- * that returns one (useful with Svelte's `bind:this` where the element
10
- * isn't available until after mount).
11
- */
12
- type ElementOrGetter = HTMLElement | (() => HTMLElement | undefined);
13
8
  /**
14
9
  * Options accepted by `useScroll`.
15
10
  */
@@ -1,13 +1,7 @@
1
1
  import { scroll } from 'motion';
2
2
  import { readable, writable } from 'svelte/store';
3
- /**
4
- * Resolves an element-or-getter to an HTMLElement (or undefined).
5
- */
6
- const resolveElement = (ref) => {
7
- if (!ref)
8
- return undefined;
9
- return typeof ref === 'function' ? ref() : ref;
10
- };
3
+ import { createAttachable } from './attachable.js';
4
+ import {} from './dom.js';
11
5
  /**
12
6
  * Creates scroll-linked Svelte stores for building scroll-driven animations
13
7
  * such as progress indicators and parallax effects.
@@ -54,29 +48,9 @@ export const useScroll = (options) => {
54
48
  scrollXProgress: writable(0),
55
49
  scrollYProgress: writable(0)
56
50
  };
57
- let cleanup;
58
- let pollRaf = 0;
59
- let subscriberCount = 0;
60
- const attach = () => {
61
- if (cleanup)
62
- return;
63
- // Resolve elements — they may not be available yet when using getters
64
- const container = resolveElement(options?.container);
65
- const target = resolveElement(options?.target);
66
- // If a getter was provided but returned undefined, the element isn't
67
- // mounted yet. Poll on the next frame until it appears.
68
- const needsContainer = options?.container && !container;
69
- const needsTarget = options?.target && !target;
70
- if (needsContainer || needsTarget) {
71
- if (!pollRaf) {
72
- pollRaf = requestAnimationFrame(() => {
73
- pollRaf = 0;
74
- attach();
75
- });
76
- }
77
- return;
78
- }
79
- cleanup = scroll((_progress, info) => {
51
+ const attachable = createAttachable({
52
+ refs: { container: options?.container, target: options?.target },
53
+ onAttach: ({ container, target }) => scroll((_progress, info) => {
80
54
  stores.scrollX.set(info.x.current);
81
55
  stores.scrollY.set(info.y.current);
82
56
  stores.scrollXProgress.set(info.x.progress);
@@ -86,28 +60,14 @@ export const useScroll = (options) => {
86
60
  target,
87
61
  offset: options?.offset,
88
62
  axis: options?.axis
89
- });
90
- };
91
- const detach = () => {
92
- if (subscriberCount <= 0) {
93
- if (pollRaf) {
94
- cancelAnimationFrame(pollRaf);
95
- pollRaf = 0;
96
- }
97
- if (cleanup) {
98
- cleanup();
99
- cleanup = undefined;
100
- }
101
- }
102
- };
63
+ })
64
+ });
103
65
  const make = (key) => readable(0, (set) => {
104
- subscriberCount++;
66
+ const release = attachable.subscribe();
105
67
  const unsub = stores[key].subscribe(set);
106
- attach();
107
68
  return () => {
108
69
  unsub();
109
- subscriberCount--;
110
- detach();
70
+ release();
111
71
  };
112
72
  });
113
73
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanspeak/svelte-motion",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "A Framer Motion-compatible animation library for Svelte 5 that provides smooth, hardware-accelerated animations. Features include spring physics, custom easing, and fluid transitions. Built on top of the motion library, it offers a simple API for creating complex animations with minimal code. Perfect for interactive UIs, micro-interactions, and engaging user experiences.",
5
5
  "keywords": [
6
6
  "svelte",