@humanspeak/svelte-motion 0.4.8 → 0.5.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.
Files changed (49) hide show
  1. package/dist/html/_MotionContainer.svelte +8 -8
  2. package/dist/index.d.ts +17 -11
  3. package/dist/index.js +9 -9
  4. package/dist/utils/attachable.js +14 -9
  5. package/dist/utils/augmentMotionValue.svelte.d.ts +156 -0
  6. package/dist/utils/augmentMotionValue.svelte.js +193 -0
  7. package/dist/utils/booleanSnapshot.svelte.d.ts +37 -0
  8. package/dist/utils/booleanSnapshot.svelte.js +48 -0
  9. package/dist/utils/dom.d.ts +13 -0
  10. package/dist/utils/dom.js +19 -0
  11. package/dist/utils/inView.svelte.d.ts +209 -0
  12. package/dist/utils/inView.svelte.js +323 -0
  13. package/dist/utils/motionTemplate.svelte.d.ts +53 -0
  14. package/dist/utils/motionTemplate.svelte.js +78 -0
  15. package/dist/utils/motionValue.svelte.d.ts +61 -0
  16. package/dist/utils/motionValue.svelte.js +49 -0
  17. package/dist/utils/reducedMotion.svelte.d.ts +43 -0
  18. package/dist/utils/reducedMotion.svelte.js +80 -0
  19. package/dist/utils/reducedMotionConfig.svelte.d.ts +74 -0
  20. package/dist/utils/reducedMotionConfig.svelte.js +144 -0
  21. package/dist/utils/scroll.svelte.d.ts +91 -0
  22. package/dist/utils/scroll.svelte.js +259 -0
  23. package/dist/utils/spring.svelte.d.ts +2 -6
  24. package/dist/utils/spring.svelte.js +6 -70
  25. package/dist/utils/time.svelte.d.ts +47 -0
  26. package/dist/utils/time.svelte.js +128 -0
  27. package/dist/utils/transform.svelte.d.ts +170 -0
  28. package/dist/utils/transform.svelte.js +189 -0
  29. package/dist/utils/velocity.svelte.d.ts +61 -0
  30. package/dist/utils/velocity.svelte.js +132 -0
  31. package/package.json +1 -1
  32. package/dist/utils/inView.d.ts +0 -136
  33. package/dist/utils/inView.js +0 -266
  34. package/dist/utils/motionTemplate.d.ts +0 -21
  35. package/dist/utils/motionTemplate.js +0 -33
  36. package/dist/utils/motionValue.d.ts +0 -6
  37. package/dist/utils/motionValue.js +0 -13
  38. package/dist/utils/reducedMotion.d.ts +0 -20
  39. package/dist/utils/reducedMotion.js +0 -42
  40. package/dist/utils/reducedMotionConfig.d.ts +0 -39
  41. package/dist/utils/reducedMotionConfig.js +0 -92
  42. package/dist/utils/scroll.d.ts +0 -63
  43. package/dist/utils/scroll.js +0 -79
  44. package/dist/utils/time.d.ts +0 -14
  45. package/dist/utils/time.js +0 -68
  46. package/dist/utils/transform.d.ts +0 -74
  47. package/dist/utils/transform.js +0 -211
  48. package/dist/utils/velocity.d.ts +0 -15
  49. package/dist/utils/velocity.js +0 -62
@@ -0,0 +1,209 @@
1
+ import { type AnimationOptions, type DOMKeyframesDefinition } from 'motion';
2
+ import type { MotionViewport } from '../types.js';
3
+ import { type BooleanSnapshot } from './booleanSnapshot.svelte.js';
4
+ import { type ElementOrGetter } from './dom.js';
5
+ /**
6
+ * Split a `whileInView` definition into the visual keyframes and an
7
+ * optional nested `transition`. Mirrors the shape framer-motion uses
8
+ * where a single object carries both the target values and their
9
+ * timing config.
10
+ *
11
+ * Defensive against `undefined` / `null` input: `def ?? {}` ensures
12
+ * destructuring never throws, and the returned `keyframes` is then an
13
+ * empty record.
14
+ *
15
+ * @param def `whileInView` record possibly carrying a nested
16
+ * `transition` config. May be `null` / `undefined` defensively (the
17
+ * spread normalises to `{}`).
18
+ * @returns Object with the keyframes (everything *except* `transition`)
19
+ * and the extracted `transition` (or `undefined` if none was nested).
20
+ *
21
+ * @example
22
+ * ```ts
23
+ * splitInViewDefinition({ opacity: 1, y: 0, transition: { duration: 0.5 } })
24
+ * // → { keyframes: { opacity: 1, y: 0 }, transition: { duration: 0.5 } }
25
+ *
26
+ * splitInViewDefinition({ opacity: 1 })
27
+ * // → { keyframes: { opacity: 1 }, transition: undefined }
28
+ * ```
29
+ */
30
+ export declare const splitInViewDefinition: (def: Record<string, unknown>) => {
31
+ keyframes: Record<string, unknown>;
32
+ transition?: AnimationOptions;
33
+ };
34
+ /**
35
+ * Compute the baseline values to restore to when an element leaves the
36
+ * viewport — only for the keys named in `whileInView`. Any key the
37
+ * element is not animating into stays as it was.
38
+ *
39
+ * For each key in `whileInView`, resolve a baseline by walking sources
40
+ * in this preference order:
41
+ *
42
+ * 1. `animate[key]` — the user's declared resting state
43
+ * 2. `initial[key]` — the pre-animation state
44
+ * 3. Neutral transform defaults (e.g. `x: 0`, `scale: 1`, `opacity: 1`)
45
+ * when the key is a known transform property
46
+ * 4. Inline CSS function value (`var(...)`, `calc(...)`, `url(...)`)
47
+ * read off `style.getPropertyValue` — handles cases where nested
48
+ * semicolons (e.g. `url(data:...;base64,...)`) would break a
49
+ * string-scrape
50
+ * 5. `getComputedStyle(el)[key]` — last resort
51
+ *
52
+ * The walk is per-key, so different baseline keys may be sourced from
53
+ * different layers.
54
+ *
55
+ * @param el Element whose computed style is read as the final fallback.
56
+ * Must be a real DOM node (the function reads inline style and
57
+ * `getComputedStyle`).
58
+ * @param opts Layered animation definitions:
59
+ * @param opts.initial Optional `initial` record from the component.
60
+ * @param opts.animate Optional `animate` record from the component.
61
+ * @param opts.whileInView The `whileInView` record — its keys drive
62
+ * which baseline entries get computed. Nested `transition` is
63
+ * stripped before walking.
64
+ * @returns A new record containing one entry per key found in
65
+ * `opts.whileInView`. May be empty if `whileInView` is empty.
66
+ *
67
+ * @example
68
+ * ```ts
69
+ * computeInViewBaseline(element, {
70
+ * initial: { opacity: 0, y: 50 },
71
+ * animate: { opacity: 1, y: 0 },
72
+ * whileInView: { opacity: 1, scale: 1.1 }
73
+ * })
74
+ * // → { opacity: 1, scale: 1 }
75
+ * // opacity sourced from animate; scale falls to the neutral default.
76
+ * ```
77
+ */
78
+ export declare const computeInViewBaseline: (el: HTMLElement, opts: {
79
+ initial?: Record<string, unknown>;
80
+ animate?: Record<string, unknown>;
81
+ whileInView?: Record<string, unknown>;
82
+ }) => Record<string, unknown>;
83
+ /**
84
+ * Wire a `whileInView` interaction onto an element using motion's
85
+ * `inView` primitive. On viewport entry the element animates to the
86
+ * supplied keyframes; on exit it animates back to a baseline computed
87
+ * via {@link computeInViewBaseline}.
88
+ *
89
+ * Used internally by `motion.<tag>` components to power the
90
+ * `whileInView` prop, and exposed for callers that want the same
91
+ * declarative behavior without going through a motion component.
92
+ *
93
+ * When `viewport.once` is `true`, the element latches on first entry
94
+ * — no exit animation runs, and the IntersectionObserver is detached
95
+ * via a `queueMicrotask(stop)` after the entry handler returns.
96
+ *
97
+ * @param el Target element to observe and animate.
98
+ * @param whileInView Keyframes to apply on entry. May carry a nested
99
+ * `transition` config (extracted via {@link splitInViewDefinition}).
100
+ * If `undefined`, the function returns a no-op cleanup without
101
+ * creating an observer.
102
+ * @param mergedTransition Default transition used both when
103
+ * `whileInView` has no nested `transition` and for the exit
104
+ * animation back to baseline.
105
+ * @param callbacks Optional lifecycle hooks:
106
+ * - `onStart` — fires on viewport entry, before the entry animation.
107
+ * - `onEnd` — fires on viewport exit, after the baseline restore
108
+ * animation kicks off. Not called when `viewport.once` is `true`.
109
+ * - `onAnimationComplete` — fires when the entry animation
110
+ * resolves; passed the keyframes that ran.
111
+ * @param baselineSources Sources for {@link computeInViewBaseline}'s
112
+ * per-key walk:
113
+ * - `initial` — the component's `initial` record.
114
+ * - `animate` — the component's `animate` record.
115
+ * @param viewport IntersectionObserver options:
116
+ * - `root` — scroll container (default page).
117
+ * - `margin` — `rootMargin` string.
118
+ * - `amount` — fraction visible required (defaults to `0` here so
119
+ * any pixel counts).
120
+ * - `once` — latch on first entry; skip exit animation.
121
+ * @returns A cleanup function that detaches the IntersectionObserver
122
+ * on call. Safe to invoke after a `once` latch has already fired.
123
+ *
124
+ * @example
125
+ * ```ts
126
+ * const cleanup = attachWhileInView(
127
+ * element,
128
+ * { opacity: 1, y: 0, transition: { duration: 0.5 } },
129
+ * { duration: 0.3 },
130
+ * {
131
+ * onStart: () => trackImpression(),
132
+ * onEnd: () => console.log('left viewport')
133
+ * },
134
+ * { initial: { opacity: 0, y: 50 } },
135
+ * { once: true, amount: 0.5 }
136
+ * )
137
+ * // Later — typically component teardown:
138
+ * cleanup()
139
+ * ```
140
+ */
141
+ export declare const attachWhileInView: (el: HTMLElement, whileInView: Record<string, unknown> | undefined, mergedTransition: AnimationOptions, callbacks?: {
142
+ onStart?: () => void;
143
+ onEnd?: () => void;
144
+ onAnimationComplete?: (definition: DOMKeyframesDefinition | undefined) => void;
145
+ }, baselineSources?: {
146
+ initial?: Record<string, unknown>;
147
+ animate?: Record<string, unknown>;
148
+ }, viewport?: MotionViewport) => (() => void);
149
+ /**
150
+ * Options accepted by `useInView`.
151
+ */
152
+ export type UseInViewOptions = {
153
+ /** Element to use as the IntersectionObserver root. Defaults to the viewport. */
154
+ root?: ElementOrGetter;
155
+ /** CSS margin string applied to the root bounding box (e.g. `"100px 0px"`). */
156
+ margin?: string;
157
+ /** Fraction (0-1) or `"some"` / `"all"` of the target that must be visible. */
158
+ amount?: 'some' | 'all' | number;
159
+ /** When `true`, the state latches to `true` on first entry and never flips back. */
160
+ once?: boolean;
161
+ /** Initial value emitted before the first IntersectionObserver callback. */
162
+ initial?: boolean;
163
+ };
164
+ /**
165
+ * State returned by {@link useInView}.
166
+ */
167
+ export type InViewState = BooleanSnapshot;
168
+ /**
169
+ * Returns an `InViewState` that tracks whether `target` is in the viewport.
170
+ * Mirrors framer-motion's `useInView` adapted for Svelte 5 runes.
171
+ *
172
+ * `target` (and `options.root`) accept either an `HTMLElement` directly or
173
+ * a getter `() => HTMLElement | undefined`. With Svelte's `bind:this` the
174
+ * element isn't available until after mount, so element resolution is
175
+ * deferred — if the element isn't ready, the hook polls on
176
+ * `requestAnimationFrame` until it is.
177
+ *
178
+ * Lifecycle: the IntersectionObserver is bound to the surrounding reactive
179
+ * scope via `$effect`. The observer attaches at mount and detaches at
180
+ * unmount, regardless of how many consumers are reading `.current` or
181
+ * `.subscribe()`. This is a deliberate divergence from the previous
182
+ * store-based impl, which attached lazily on first subscribe.
183
+ *
184
+ * SSR-safe: returns a static `{ current: options.initial ?? false }` when
185
+ * `window` or `IntersectionObserver` is unavailable.
186
+ *
187
+ * @param target - Element (or getter) to observe.
188
+ * @param options - Optional `UseInViewOptions` (`root`, `margin`, `amount`,
189
+ * `once`, `initial`).
190
+ * @returns A `InViewState` reflecting the target's viewport intersection.
191
+ * @see https://motion.dev/docs/react-use-in-view
192
+ *
193
+ * @example
194
+ * ```svelte
195
+ * <script>
196
+ * import { useInView } from '@humanspeak/svelte-motion'
197
+ *
198
+ * let ref
199
+ * const inView = useInView(() => ref, { once: true })
200
+ *
201
+ * $effect(() => {
202
+ * if (inView.current) trackImpression()
203
+ * })
204
+ * </script>
205
+ *
206
+ * <div bind:this={ref}>{inView.current ? 'visible' : 'hidden'}</div>
207
+ * ```
208
+ */
209
+ export declare const useInView: (target: ElementOrGetter, options?: UseInViewOptions) => InViewState;
@@ -0,0 +1,323 @@
1
+ import { animate, inView as motionInView } from 'motion';
2
+ import { createAttachable } from './attachable.js';
3
+ import { createBooleanSnapshot } from './booleanSnapshot.svelte.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
+ */
19
+ const getInlineCssFunction = (el, propName) => {
20
+ const kebab = propName.replace(/([A-Z])/g, '-$1').toLowerCase();
21
+ const value = el.style.getPropertyValue(kebab).trim();
22
+ if (!value)
23
+ return null;
24
+ return CSS_FUNCTION_RE.test(value) ? value : null;
25
+ };
26
+ /**
27
+ * Split a `whileInView` definition into the visual keyframes and an
28
+ * optional nested `transition`. Mirrors the shape framer-motion uses
29
+ * where a single object carries both the target values and their
30
+ * timing config.
31
+ *
32
+ * Defensive against `undefined` / `null` input: `def ?? {}` ensures
33
+ * destructuring never throws, and the returned `keyframes` is then an
34
+ * empty record.
35
+ *
36
+ * @param def `whileInView` record possibly carrying a nested
37
+ * `transition` config. May be `null` / `undefined` defensively (the
38
+ * spread normalises to `{}`).
39
+ * @returns Object with the keyframes (everything *except* `transition`)
40
+ * and the extracted `transition` (or `undefined` if none was nested).
41
+ *
42
+ * @example
43
+ * ```ts
44
+ * splitInViewDefinition({ opacity: 1, y: 0, transition: { duration: 0.5 } })
45
+ * // → { keyframes: { opacity: 1, y: 0 }, transition: { duration: 0.5 } }
46
+ *
47
+ * splitInViewDefinition({ opacity: 1 })
48
+ * // → { keyframes: { opacity: 1 }, transition: undefined }
49
+ * ```
50
+ */
51
+ export const splitInViewDefinition = (def) => {
52
+ const { transition, ...rest } = (def ?? {});
53
+ return { keyframes: rest, transition };
54
+ };
55
+ /**
56
+ * Compute the baseline values to restore to when an element leaves the
57
+ * viewport — only for the keys named in `whileInView`. Any key the
58
+ * element is not animating into stays as it was.
59
+ *
60
+ * For each key in `whileInView`, resolve a baseline by walking sources
61
+ * in this preference order:
62
+ *
63
+ * 1. `animate[key]` — the user's declared resting state
64
+ * 2. `initial[key]` — the pre-animation state
65
+ * 3. Neutral transform defaults (e.g. `x: 0`, `scale: 1`, `opacity: 1`)
66
+ * when the key is a known transform property
67
+ * 4. Inline CSS function value (`var(...)`, `calc(...)`, `url(...)`)
68
+ * read off `style.getPropertyValue` — handles cases where nested
69
+ * semicolons (e.g. `url(data:...;base64,...)`) would break a
70
+ * string-scrape
71
+ * 5. `getComputedStyle(el)[key]` — last resort
72
+ *
73
+ * The walk is per-key, so different baseline keys may be sourced from
74
+ * different layers.
75
+ *
76
+ * @param el Element whose computed style is read as the final fallback.
77
+ * Must be a real DOM node (the function reads inline style and
78
+ * `getComputedStyle`).
79
+ * @param opts Layered animation definitions:
80
+ * @param opts.initial Optional `initial` record from the component.
81
+ * @param opts.animate Optional `animate` record from the component.
82
+ * @param opts.whileInView The `whileInView` record — its keys drive
83
+ * which baseline entries get computed. Nested `transition` is
84
+ * stripped before walking.
85
+ * @returns A new record containing one entry per key found in
86
+ * `opts.whileInView`. May be empty if `whileInView` is empty.
87
+ *
88
+ * @example
89
+ * ```ts
90
+ * computeInViewBaseline(element, {
91
+ * initial: { opacity: 0, y: 50 },
92
+ * animate: { opacity: 1, y: 0 },
93
+ * whileInView: { opacity: 1, scale: 1.1 }
94
+ * })
95
+ * // → { opacity: 1, scale: 1 }
96
+ * // opacity sourced from animate; scale falls to the neutral default.
97
+ * ```
98
+ */
99
+ export const computeInViewBaseline = (el, opts) => {
100
+ const baseline = {};
101
+ const initialRecord = (opts.initial ?? {});
102
+ const animateRecord = (opts.animate ?? {});
103
+ const whileInViewRecordRaw = (opts.whileInView ?? {});
104
+ const whileInViewRecord = { ...whileInViewRecordRaw };
105
+ delete whileInViewRecord.transition;
106
+ const neutralTransformDefaults = {
107
+ x: 0,
108
+ y: 0,
109
+ translateX: 0,
110
+ translateY: 0,
111
+ scale: 1,
112
+ scaleX: 1,
113
+ scaleY: 1,
114
+ rotate: 0,
115
+ rotateX: 0,
116
+ rotateY: 0,
117
+ rotateZ: 0,
118
+ skewX: 0,
119
+ skewY: 0,
120
+ opacity: 1
121
+ };
122
+ const cs = getComputedStyle(el);
123
+ for (const key of Object.keys(whileInViewRecord)) {
124
+ if (Object.prototype.hasOwnProperty.call(animateRecord, key)) {
125
+ baseline[key] = animateRecord[key];
126
+ }
127
+ else if (Object.prototype.hasOwnProperty.call(initialRecord, key)) {
128
+ baseline[key] = initialRecord[key];
129
+ }
130
+ else if (key in neutralTransformDefaults) {
131
+ baseline[key] = neutralTransformDefaults[key];
132
+ }
133
+ else {
134
+ const inlineValue = getInlineCssFunction(el, key);
135
+ if (inlineValue) {
136
+ baseline[key] = inlineValue;
137
+ }
138
+ else if (key in cs) {
139
+ baseline[key] = cs[key];
140
+ }
141
+ }
142
+ }
143
+ return baseline;
144
+ };
145
+ /**
146
+ * Wire a `whileInView` interaction onto an element using motion's
147
+ * `inView` primitive. On viewport entry the element animates to the
148
+ * supplied keyframes; on exit it animates back to a baseline computed
149
+ * via {@link computeInViewBaseline}.
150
+ *
151
+ * Used internally by `motion.<tag>` components to power the
152
+ * `whileInView` prop, and exposed for callers that want the same
153
+ * declarative behavior without going through a motion component.
154
+ *
155
+ * When `viewport.once` is `true`, the element latches on first entry
156
+ * — no exit animation runs, and the IntersectionObserver is detached
157
+ * via a `queueMicrotask(stop)` after the entry handler returns.
158
+ *
159
+ * @param el Target element to observe and animate.
160
+ * @param whileInView Keyframes to apply on entry. May carry a nested
161
+ * `transition` config (extracted via {@link splitInViewDefinition}).
162
+ * If `undefined`, the function returns a no-op cleanup without
163
+ * creating an observer.
164
+ * @param mergedTransition Default transition used both when
165
+ * `whileInView` has no nested `transition` and for the exit
166
+ * animation back to baseline.
167
+ * @param callbacks Optional lifecycle hooks:
168
+ * - `onStart` — fires on viewport entry, before the entry animation.
169
+ * - `onEnd` — fires on viewport exit, after the baseline restore
170
+ * animation kicks off. Not called when `viewport.once` is `true`.
171
+ * - `onAnimationComplete` — fires when the entry animation
172
+ * resolves; passed the keyframes that ran.
173
+ * @param baselineSources Sources for {@link computeInViewBaseline}'s
174
+ * per-key walk:
175
+ * - `initial` — the component's `initial` record.
176
+ * - `animate` — the component's `animate` record.
177
+ * @param viewport IntersectionObserver options:
178
+ * - `root` — scroll container (default page).
179
+ * - `margin` — `rootMargin` string.
180
+ * - `amount` — fraction visible required (defaults to `0` here so
181
+ * any pixel counts).
182
+ * - `once` — latch on first entry; skip exit animation.
183
+ * @returns A cleanup function that detaches the IntersectionObserver
184
+ * on call. Safe to invoke after a `once` latch has already fired.
185
+ *
186
+ * @example
187
+ * ```ts
188
+ * const cleanup = attachWhileInView(
189
+ * element,
190
+ * { opacity: 1, y: 0, transition: { duration: 0.5 } },
191
+ * { duration: 0.3 },
192
+ * {
193
+ * onStart: () => trackImpression(),
194
+ * onEnd: () => console.log('left viewport')
195
+ * },
196
+ * { initial: { opacity: 0, y: 50 } },
197
+ * { once: true, amount: 0.5 }
198
+ * )
199
+ * // Later — typically component teardown:
200
+ * cleanup()
201
+ * ```
202
+ */
203
+ export const attachWhileInView = (el, whileInView, mergedTransition, callbacks, baselineSources, viewport) => {
204
+ if (!whileInView)
205
+ return () => { };
206
+ let latched = false;
207
+ const stop = motionInView(el, () => {
208
+ if (latched)
209
+ return;
210
+ const inViewBaseline = computeInViewBaseline(el, {
211
+ initial: baselineSources?.initial,
212
+ animate: baselineSources?.animate,
213
+ whileInView
214
+ });
215
+ callbacks?.onStart?.();
216
+ const { keyframes, transition } = splitInViewDefinition(whileInView);
217
+ const animation = animate(el, keyframes, (transition ?? mergedTransition));
218
+ animation.finished
219
+ .then(() => {
220
+ callbacks?.onAnimationComplete?.(keyframes);
221
+ })
222
+ .catch(() => {
223
+ /* animation cancelled — skip completion callback */
224
+ });
225
+ if (viewport?.once) {
226
+ latched = true;
227
+ queueMicrotask(stop);
228
+ return;
229
+ }
230
+ return () => {
231
+ if (Object.keys(inViewBaseline).length > 0) {
232
+ animate(el, inViewBaseline, mergedTransition);
233
+ }
234
+ callbacks?.onEnd?.();
235
+ };
236
+ }, {
237
+ root: viewport?.root,
238
+ margin: viewport?.margin,
239
+ amount: viewport?.amount ?? 0
240
+ });
241
+ return stop;
242
+ };
243
+ /**
244
+ * Returns an `InViewState` that tracks whether `target` is in the viewport.
245
+ * Mirrors framer-motion's `useInView` adapted for Svelte 5 runes.
246
+ *
247
+ * `target` (and `options.root`) accept either an `HTMLElement` directly or
248
+ * a getter `() => HTMLElement | undefined`. With Svelte's `bind:this` the
249
+ * element isn't available until after mount, so element resolution is
250
+ * deferred — if the element isn't ready, the hook polls on
251
+ * `requestAnimationFrame` until it is.
252
+ *
253
+ * Lifecycle: the IntersectionObserver is bound to the surrounding reactive
254
+ * scope via `$effect`. The observer attaches at mount and detaches at
255
+ * unmount, regardless of how many consumers are reading `.current` or
256
+ * `.subscribe()`. This is a deliberate divergence from the previous
257
+ * store-based impl, which attached lazily on first subscribe.
258
+ *
259
+ * SSR-safe: returns a static `{ current: options.initial ?? false }` when
260
+ * `window` or `IntersectionObserver` is unavailable.
261
+ *
262
+ * @param target - Element (or getter) to observe.
263
+ * @param options - Optional `UseInViewOptions` (`root`, `margin`, `amount`,
264
+ * `once`, `initial`).
265
+ * @returns A `InViewState` reflecting the target's viewport intersection.
266
+ * @see https://motion.dev/docs/react-use-in-view
267
+ *
268
+ * @example
269
+ * ```svelte
270
+ * <script>
271
+ * import { useInView } from '@humanspeak/svelte-motion'
272
+ *
273
+ * let ref
274
+ * const inView = useInView(() => ref, { once: true })
275
+ *
276
+ * $effect(() => {
277
+ * if (inView.current) trackImpression()
278
+ * })
279
+ * </script>
280
+ *
281
+ * <div bind:this={ref}>{inView.current ? 'visible' : 'hidden'}</div>
282
+ * ```
283
+ */
284
+ export const useInView = (target, options = {}) => {
285
+ const initial = options.initial ?? false;
286
+ if (typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') {
287
+ return {
288
+ get current() {
289
+ return initial;
290
+ },
291
+ subscribe(run) {
292
+ run(initial);
293
+ return () => undefined;
294
+ }
295
+ };
296
+ }
297
+ const [state, set] = createBooleanSnapshot(initial);
298
+ let latched = false;
299
+ const attachable = createAttachable({
300
+ refs: { target, root: options.root },
301
+ isLatched: () => latched,
302
+ onAttach: ({ target: el, root }, stop) => motionInView(el, () => {
303
+ set(true);
304
+ if (options.once) {
305
+ // Detach inside the entry callback; motion's inView
306
+ // handles re-entry safely via observer.unobserve.
307
+ latched = true;
308
+ stop();
309
+ return;
310
+ }
311
+ return () => set(false);
312
+ }, {
313
+ root: root,
314
+ // framer-motion types `margin` as a CSS-shorthand template
315
+ // literal; we expose plain `string` so the public API is
316
+ // ergonomic and forward-compat with future motion changes.
317
+ margin: options.margin,
318
+ amount: options.amount
319
+ })
320
+ });
321
+ $effect(() => attachable.subscribe());
322
+ return state;
323
+ };
@@ -0,0 +1,53 @@
1
+ import { type Readable } from 'svelte/store';
2
+ import { type AugmentedMotionValue } from './augmentMotionValue.svelte.js';
3
+ /**
4
+ * Input to {@link useMotionTemplate}'s interpolation slots: a motion-dom
5
+ * `MotionValue`, a Svelte readable, or a plain `number` / `string` literal.
6
+ * Mirrors framer-motion's `useMotionTemplate` signature.
7
+ */
8
+ export type MotionTemplateInput = AugmentedMotionValue<number | string> | Readable<number | string> | number | string;
9
+ /**
10
+ * Tagged template literal that builds an augmented `MotionValue<string>`
11
+ * from interpolated motion values, Svelte readables, and plain literals.
12
+ *
13
+ * Mirrors framer-motion 1:1: returns a real motion-dom `MotionValue<string>`
14
+ * (via `transformValue`) that auto-tracks every `MotionValue.get()` called
15
+ * during template composition. Whenever any tracked input emits, the
16
+ * composer reruns and writes the new string into the result value.
17
+ *
18
+ * Svelte-readable slots are sampled via `svelte/store`'s `get()` inside the
19
+ * composer so they re-emit when adjacent motion values trigger a recompute.
20
+ * Plain `number` / `string` slots are stringified inline.
21
+ *
22
+ * The result is augmented with a `$state`-backed `.current` getter and a
23
+ * Svelte readable `.subscribe` shim so it composes with the rest of the
24
+ * Tier 2 surface and reads reactively in Svelte 5 scopes.
25
+ *
26
+ * Lifecycle: must be called during component initialization. motion-dom
27
+ * cleans up the change-subscriptions when the result `MotionValue` is
28
+ * destroyed; we wire that destroy to the surrounding `$effect`.
29
+ *
30
+ * SSR-safe: motion-dom's `transformValue` works without DOM access (no
31
+ * timers, no listeners). On the server the result is a static augmented
32
+ * motion value with no `$effect` registered.
33
+ *
34
+ * @param strings Static template string parts (supplied by the tagged-template syntax).
35
+ * @param values Interpolated motion values, Svelte readables, or literals.
36
+ * @returns An `AugmentedMotionValue<string>` with the composed template.
37
+ *
38
+ * @example
39
+ * ```svelte
40
+ * <script>
41
+ * import { useSpring, useTransform, useMotionTemplate } from '@humanspeak/svelte-motion'
42
+ *
43
+ * const x = useSpring(0)
44
+ * const blur = useTransform(x, [-100, 0, 100], [10, 0, 10])
45
+ * const filter = useMotionTemplate`blur(${blur}px)`
46
+ * </script>
47
+ *
48
+ * <div style="filter: {filter.current}">Animated blur</div>
49
+ * ```
50
+ *
51
+ * @see https://motion.dev/docs/react-use-motion-template
52
+ */
53
+ export declare const useMotionTemplate: (strings: TemplateStringsArray, ...values: MotionTemplateInput[]) => AugmentedMotionValue<string>;
@@ -0,0 +1,78 @@
1
+ import { isMotionValue, transformValue } from 'motion-dom';
2
+ import {} from 'svelte/store';
3
+ import { augmentMotionValue, sampleSource } from './augmentMotionValue.svelte.js';
4
+ /**
5
+ * Tagged template literal that builds an augmented `MotionValue<string>`
6
+ * from interpolated motion values, Svelte readables, and plain literals.
7
+ *
8
+ * Mirrors framer-motion 1:1: returns a real motion-dom `MotionValue<string>`
9
+ * (via `transformValue`) that auto-tracks every `MotionValue.get()` called
10
+ * during template composition. Whenever any tracked input emits, the
11
+ * composer reruns and writes the new string into the result value.
12
+ *
13
+ * Svelte-readable slots are sampled via `svelte/store`'s `get()` inside the
14
+ * composer so they re-emit when adjacent motion values trigger a recompute.
15
+ * Plain `number` / `string` slots are stringified inline.
16
+ *
17
+ * The result is augmented with a `$state`-backed `.current` getter and a
18
+ * Svelte readable `.subscribe` shim so it composes with the rest of the
19
+ * Tier 2 surface and reads reactively in Svelte 5 scopes.
20
+ *
21
+ * Lifecycle: must be called during component initialization. motion-dom
22
+ * cleans up the change-subscriptions when the result `MotionValue` is
23
+ * destroyed; we wire that destroy to the surrounding `$effect`.
24
+ *
25
+ * SSR-safe: motion-dom's `transformValue` works without DOM access (no
26
+ * timers, no listeners). On the server the result is a static augmented
27
+ * motion value with no `$effect` registered.
28
+ *
29
+ * @param strings Static template string parts (supplied by the tagged-template syntax).
30
+ * @param values Interpolated motion values, Svelte readables, or literals.
31
+ * @returns An `AugmentedMotionValue<string>` with the composed template.
32
+ *
33
+ * @example
34
+ * ```svelte
35
+ * <script>
36
+ * import { useSpring, useTransform, useMotionTemplate } from '@humanspeak/svelte-motion'
37
+ *
38
+ * const x = useSpring(0)
39
+ * const blur = useTransform(x, [-100, 0, 100], [10, 0, 10])
40
+ * const filter = useMotionTemplate`blur(${blur}px)`
41
+ * </script>
42
+ *
43
+ * <div style="filter: {filter.current}">Animated blur</div>
44
+ * ```
45
+ *
46
+ * @see https://motion.dev/docs/react-use-motion-template
47
+ */
48
+ export const useMotionTemplate = (strings, ...values) => {
49
+ const numFragments = strings.length;
50
+ const buildValue = () => {
51
+ let output = '';
52
+ for (let i = 0; i < numFragments; i++) {
53
+ output += strings[i] ?? '';
54
+ if (i >= values.length)
55
+ continue;
56
+ const value = values[i];
57
+ // motion-dom's collectMotionValues session inside transformValue
58
+ // auto-discovers MotionValue deps via `.get()`. Readables don't
59
+ // participate — they're sampled inline via get(); they only
60
+ // re-emit if some adjacent motion value triggers a recompute.
61
+ if (isMotionValue(value)) {
62
+ output += String(value.get());
63
+ }
64
+ else if (value && typeof value === 'object' && 'subscribe' in value) {
65
+ output += String(sampleSource(value));
66
+ }
67
+ else {
68
+ output += String(value);
69
+ }
70
+ }
71
+ return output;
72
+ };
73
+ const result = transformValue(buildValue);
74
+ if (typeof window !== 'undefined') {
75
+ $effect(() => () => result.destroy());
76
+ }
77
+ return augmentMotionValue(result);
78
+ };