@humanspeak/svelte-motion 0.4.8 → 0.4.9

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.
@@ -1,136 +0,0 @@
1
- import { type AnimationOptions, type DOMKeyframesDefinition } from 'motion';
2
- import { type Readable } from 'svelte/store';
3
- import type { MotionViewport } from '../types.js';
4
- import { type ElementOrGetter } from './dom.js';
5
- /**
6
- * Split a whileInView definition into keyframes and an optional nested transition.
7
- *
8
- * @param def While-in-view record that may include a nested `transition`.
9
- * @returns Object with `keyframes` (no `transition`) and optional `transition`.
10
- * @example
11
- * // With transition
12
- * splitInViewDefinition({ opacity: 1, y: 0, transition: { duration: 0.5 } })
13
- * // => { keyframes: { opacity: 1, y: 0 }, transition: { duration: 0.5 } }
14
- *
15
- * @example
16
- * // Without transition
17
- * splitInViewDefinition({ opacity: 1, scale: 1 })
18
- * // => { keyframes: { opacity: 1, scale: 1 }, transition: undefined }
19
- */
20
- export declare const splitInViewDefinition: (def: Record<string, unknown>) => {
21
- keyframes: Record<string, unknown>;
22
- transition?: AnimationOptions;
23
- };
24
- /**
25
- * Compute the baseline values to restore to when element leaves viewport.
26
- *
27
- * Preference order per key: `animate` → `initial` → neutral transform defaults
28
- * → computed style value if present.
29
- *
30
- * @param el Target element.
31
- * @param opts Source records for baseline computation.
32
- * @returns Minimal baseline record to restore when element leaves viewport.
33
- * @example
34
- * computeInViewBaseline(element, {
35
- * initial: { opacity: 0, y: 50 },
36
- * animate: { opacity: 1, y: 0 },
37
- * whileInView: { opacity: 1, scale: 1.1 }
38
- * })
39
- * // => { opacity: 1, scale: 1 } (scale defaults to 1, opacity from animate)
40
- */
41
- export declare const computeInViewBaseline: (el: HTMLElement, opts: {
42
- initial?: Record<string, unknown>;
43
- animate?: Record<string, unknown>;
44
- whileInView?: Record<string, unknown>;
45
- }) => Record<string, unknown>;
46
- /**
47
- * Attach whileInView interactions to an element via motion's `inView` primitive.
48
- *
49
- * On entry, animates to `whileInView` state (using the nested `transition` if
50
- * provided). On exit, restores the changed keys to a baseline computed from
51
- * `initial` / `animate` / neutral transform defaults / inline styles.
52
- *
53
- * Delegates to motion's `inView` so the IntersectionObserver implementation
54
- * is shared with the public `useInView` hook.
55
- *
56
- * @param el Target element.
57
- * @param whileInView While-in-view definition.
58
- * @param mergedTransition Root/component merged transition.
59
- * @param callbacks Optional lifecycle callbacks for in-view start/end and animation completion.
60
- * @param baselineSources Optional sources used to compute baseline.
61
- * @param viewport Optional IntersectionObserver options. `amount` defaults to `0` (any pixel visible).
62
- * @returns Cleanup function to stop observing.
63
- * @example
64
- * const cleanup = attachWhileInView(
65
- * element,
66
- * { opacity: 1, y: 0, transition: { duration: 0.5 } },
67
- * { duration: 0.3 },
68
- * {
69
- * onStart: () => console.log('Entered viewport'),
70
- * onEnd: () => console.log('Left viewport')
71
- * },
72
- * { initial: { opacity: 0, y: 50 } },
73
- * { once: true, amount: 0.5 }
74
- * )
75
- * // Later: cleanup() to stop observing
76
- */
77
- export declare const attachWhileInView: (el: HTMLElement, whileInView: Record<string, unknown> | undefined, mergedTransition: AnimationOptions, callbacks?: {
78
- onStart?: () => void;
79
- onEnd?: () => void;
80
- onAnimationComplete?: (definition: DOMKeyframesDefinition | undefined) => void;
81
- }, baselineSources?: {
82
- initial?: Record<string, unknown>;
83
- animate?: Record<string, unknown>;
84
- }, viewport?: MotionViewport) => (() => void);
85
- /**
86
- * Options accepted by `useInView`.
87
- */
88
- export type UseInViewOptions = {
89
- /** Element to use as the IntersectionObserver root. Defaults to the viewport. */
90
- root?: ElementOrGetter;
91
- /** CSS margin string applied to the root bounding box (e.g. `"100px 0px"`). */
92
- margin?: string;
93
- /** Fraction (0-1) or `"some"` / `"all"` of the target that must be visible. */
94
- amount?: 'some' | 'all' | number;
95
- /** When `true`, the store latches to `true` on first entry and never flips back. */
96
- once?: boolean;
97
- /** Initial value emitted before the first IntersectionObserver callback. */
98
- initial?: boolean;
99
- };
100
- /**
101
- * Returns a Svelte readable store that tracks whether `target` is in the
102
- * viewport. Mirrors Framer Motion's `useInView` so the same options
103
- * (`root`, `margin`, `amount`, `once`, `initial`) work as in React.
104
- *
105
- * `target` (and `options.root`) accept either an `HTMLElement` directly or a
106
- * getter `() => HTMLElement | undefined`. With Svelte's `bind:this`, the
107
- * element isn't available until after mount, so element resolution is
108
- * deferred until the first subscriber arrives; if the element isn't ready,
109
- * the hook polls on `requestAnimationFrame` until it is.
110
- *
111
- * SSR-safe: returns a static `readable(initial)` when `window` or
112
- * `IntersectionObserver` is unavailable.
113
- *
114
- * @param target - Element (or getter) to observe.
115
- * @param options - Optional `UseInViewOptions` (`root`, `margin`, `amount`,
116
- * `once`, `initial`).
117
- * @returns A `Readable<boolean>` that flips to `true` while `target` is in view.
118
- * @see https://motion.dev/docs/react-use-in-view
119
- *
120
- * @example
121
- * ```svelte
122
- * <script>
123
- * import { useInView } from '@humanspeak/svelte-motion'
124
- *
125
- * let ref
126
- * const inView = useInView(() => ref, { once: true })
127
- *
128
- * $effect(() => {
129
- * if ($inView) trackImpression()
130
- * })
131
- * </script>
132
- *
133
- * <div bind:this={ref}>{$inView ? 'visible' : 'hidden'}</div>
134
- * ```
135
- */
136
- export declare const useInView: (target: ElementOrGetter, options?: UseInViewOptions) => Readable<boolean>;
@@ -1,266 +0,0 @@
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
- };
28
- /**
29
- * Split a whileInView definition into keyframes and an optional nested transition.
30
- *
31
- * @param def While-in-view record that may include a nested `transition`.
32
- * @returns Object with `keyframes` (no `transition`) and optional `transition`.
33
- * @example
34
- * // With transition
35
- * splitInViewDefinition({ opacity: 1, y: 0, transition: { duration: 0.5 } })
36
- * // => { keyframes: { opacity: 1, y: 0 }, transition: { duration: 0.5 } }
37
- *
38
- * @example
39
- * // Without transition
40
- * splitInViewDefinition({ opacity: 1, scale: 1 })
41
- * // => { keyframes: { opacity: 1, scale: 1 }, transition: undefined }
42
- */
43
- export const splitInViewDefinition = (def) => {
44
- const { transition, ...rest } = (def ?? {});
45
- return { keyframes: rest, transition };
46
- };
47
- /**
48
- * Compute the baseline values to restore to when element leaves viewport.
49
- *
50
- * Preference order per key: `animate` → `initial` → neutral transform defaults
51
- * → computed style value if present.
52
- *
53
- * @param el Target element.
54
- * @param opts Source records for baseline computation.
55
- * @returns Minimal baseline record to restore when element leaves viewport.
56
- * @example
57
- * computeInViewBaseline(element, {
58
- * initial: { opacity: 0, y: 50 },
59
- * animate: { opacity: 1, y: 0 },
60
- * whileInView: { opacity: 1, scale: 1.1 }
61
- * })
62
- * // => { opacity: 1, scale: 1 } (scale defaults to 1, opacity from animate)
63
- */
64
- export const computeInViewBaseline = (el, opts) => {
65
- const baseline = {};
66
- const initialRecord = (opts.initial ?? {});
67
- const animateRecord = (opts.animate ?? {});
68
- const whileInViewRecordRaw = (opts.whileInView ?? {});
69
- const whileInViewRecord = { ...whileInViewRecordRaw };
70
- delete whileInViewRecord.transition;
71
- const neutralTransformDefaults = {
72
- x: 0,
73
- y: 0,
74
- translateX: 0,
75
- translateY: 0,
76
- scale: 1,
77
- scaleX: 1,
78
- scaleY: 1,
79
- rotate: 0,
80
- rotateX: 0,
81
- rotateY: 0,
82
- rotateZ: 0,
83
- skewX: 0,
84
- skewY: 0,
85
- opacity: 1
86
- };
87
- const cs = getComputedStyle(el);
88
- for (const key of Object.keys(whileInViewRecord)) {
89
- if (Object.prototype.hasOwnProperty.call(animateRecord, key)) {
90
- baseline[key] = animateRecord[key];
91
- }
92
- else if (Object.prototype.hasOwnProperty.call(initialRecord, key)) {
93
- baseline[key] = initialRecord[key];
94
- }
95
- else if (key in neutralTransformDefaults) {
96
- baseline[key] = neutralTransformDefaults[key];
97
- }
98
- else {
99
- const inlineValue = getInlineCssFunction(el, key);
100
- if (inlineValue) {
101
- baseline[key] = inlineValue;
102
- }
103
- else if (key in cs) {
104
- baseline[key] = cs[key];
105
- }
106
- }
107
- }
108
- return baseline;
109
- };
110
- /**
111
- * Attach whileInView interactions to an element via motion's `inView` primitive.
112
- *
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.
116
- *
117
- * Delegates to motion's `inView` so the IntersectionObserver implementation
118
- * is shared with the public `useInView` hook.
119
- *
120
- * @param el Target element.
121
- * @param whileInView While-in-view definition.
122
- * @param mergedTransition Root/component merged transition.
123
- * @param callbacks Optional lifecycle callbacks for in-view start/end and animation completion.
124
- * @param baselineSources Optional sources used to compute baseline.
125
- * @param viewport Optional IntersectionObserver options. `amount` defaults to `0` (any pixel visible).
126
- * @returns Cleanup function to stop observing.
127
- * @example
128
- * const cleanup = attachWhileInView(
129
- * element,
130
- * { opacity: 1, y: 0, transition: { duration: 0.5 } },
131
- * { duration: 0.3 },
132
- * {
133
- * onStart: () => console.log('Entered viewport'),
134
- * onEnd: () => console.log('Left viewport')
135
- * },
136
- * { initial: { opacity: 0, y: 50 } },
137
- * { once: true, amount: 0.5 }
138
- * )
139
- * // Later: cleanup() to stop observing
140
- */
141
- export const attachWhileInView = (el, whileInView, mergedTransition, callbacks, baselineSources, viewport) => {
142
- if (!whileInView)
143
- return () => { };
144
- let latched = false;
145
- const stop = motionInView(el, () => {
146
- if (latched)
147
- return;
148
- const inViewBaseline = computeInViewBaseline(el, {
149
- initial: baselineSources?.initial,
150
- animate: baselineSources?.animate,
151
- whileInView
152
- });
153
- callbacks?.onStart?.();
154
- const { keyframes, transition } = splitInViewDefinition(whileInView);
155
- const animation = animate(el, keyframes, (transition ?? mergedTransition));
156
- animation.finished
157
- .then(() => {
158
- callbacks?.onAnimationComplete?.(keyframes);
159
- })
160
- .catch(() => {
161
- /* animation cancelled — skip completion callback */
162
- });
163
- if (viewport?.once) {
164
- // Latch on first entry. Don't return an exit handler so the
165
- // element holds its in-view state and we never animate back.
166
- // Stop observing entirely after the entry handler returns.
167
- latched = true;
168
- queueMicrotask(stop);
169
- return;
170
- }
171
- return () => {
172
- if (Object.keys(inViewBaseline).length > 0) {
173
- animate(el, inViewBaseline, mergedTransition);
174
- }
175
- callbacks?.onEnd?.();
176
- };
177
- }, {
178
- root: viewport?.root,
179
- // framer-motion types `margin` as a CSS-shorthand template literal;
180
- // we expose plain `string` so consumers can pass any computed value.
181
- margin: viewport?.margin,
182
- amount: viewport?.amount ?? 0
183
- });
184
- return stop;
185
- };
186
- /**
187
- * Returns a Svelte readable store that tracks whether `target` is in the
188
- * viewport. Mirrors Framer Motion's `useInView` so the same options
189
- * (`root`, `margin`, `amount`, `once`, `initial`) work as in React.
190
- *
191
- * `target` (and `options.root`) accept either an `HTMLElement` directly or a
192
- * getter `() => HTMLElement | undefined`. With Svelte's `bind:this`, the
193
- * element isn't available until after mount, so element resolution is
194
- * deferred until the first subscriber arrives; if the element isn't ready,
195
- * the hook polls on `requestAnimationFrame` until it is.
196
- *
197
- * SSR-safe: returns a static `readable(initial)` when `window` or
198
- * `IntersectionObserver` is unavailable.
199
- *
200
- * @param target - Element (or getter) to observe.
201
- * @param options - Optional `UseInViewOptions` (`root`, `margin`, `amount`,
202
- * `once`, `initial`).
203
- * @returns A `Readable<boolean>` that flips to `true` while `target` is in view.
204
- * @see https://motion.dev/docs/react-use-in-view
205
- *
206
- * @example
207
- * ```svelte
208
- * <script>
209
- * import { useInView } from '@humanspeak/svelte-motion'
210
- *
211
- * let ref
212
- * const inView = useInView(() => ref, { once: true })
213
- *
214
- * $effect(() => {
215
- * if ($inView) trackImpression()
216
- * })
217
- * </script>
218
- *
219
- * <div bind:this={ref}>{$inView ? 'visible' : 'hidden'}</div>
220
- * ```
221
- */
222
- export const useInView = (target, options = {}) => {
223
- const initial = options.initial ?? false;
224
- if (typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') {
225
- return readable(initial);
226
- }
227
- const store = writable(initial);
228
- let current = initial;
229
- const update = (next) => {
230
- if (next === current)
231
- return;
232
- current = next;
233
- store.set(next);
234
- };
235
- let latched = false;
236
- const attachable = createAttachable({
237
- refs: { target, root: options.root },
238
- isLatched: () => latched,
239
- onAttach: ({ target: el, root }, stop) => motionInView(el, () => {
240
- update(true);
241
- if (options.once) {
242
- // Detach inside the entry callback; motion's inView
243
- // handles re-entry safely via observer.unobserve.
244
- latched = true;
245
- stop();
246
- return;
247
- }
248
- return () => update(false);
249
- }, {
250
- root: root,
251
- // framer-motion types `margin` as a CSS-shorthand template
252
- // literal; we expose plain `string` so the public API is
253
- // ergonomic and forward-compat with future motion changes.
254
- margin: options.margin,
255
- amount: options.amount
256
- })
257
- });
258
- return readable(initial, (set) => {
259
- const release = attachable.subscribe();
260
- const unsub = store.subscribe(set);
261
- return () => {
262
- unsub();
263
- release();
264
- };
265
- });
266
- };
@@ -1,20 +0,0 @@
1
- import { type Readable } from 'svelte/store';
2
- /**
3
- * Returns a readable store that reflects the user's `prefers-reduced-motion` setting.
4
- *
5
- * Defaults to `false` in SSR or when `matchMedia` is unavailable/throws.
6
- *
7
- * @returns {Readable<boolean>} `true` when the user prefers reduced motion.
8
- * @see https://motion.dev/docs/react-reduced-motion
9
- *
10
- * @example
11
- * ```svelte
12
- * <script>
13
- * import { useReducedMotion } from '@humanspeak/svelte-motion'
14
- * const reduced = useReducedMotion()
15
- * </script>
16
- *
17
- * <div style:transform={$reduced ? 'none' : 'rotate(45deg)'}>...</div>
18
- * ```
19
- */
20
- export declare const useReducedMotion: () => Readable<boolean>;
@@ -1,42 +0,0 @@
1
- import { readable } from 'svelte/store';
2
- const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
3
- const SSR_FALLBACK = readable(false, () => { });
4
- /**
5
- * Returns a readable store that reflects the user's `prefers-reduced-motion` setting.
6
- *
7
- * Defaults to `false` in SSR or when `matchMedia` is unavailable/throws.
8
- *
9
- * @returns {Readable<boolean>} `true` when the user prefers reduced motion.
10
- * @see https://motion.dev/docs/react-reduced-motion
11
- *
12
- * @example
13
- * ```svelte
14
- * <script>
15
- * import { useReducedMotion } from '@humanspeak/svelte-motion'
16
- * const reduced = useReducedMotion()
17
- * </script>
18
- *
19
- * <div style:transform={$reduced ? 'none' : 'rotate(45deg)'}>...</div>
20
- * ```
21
- */
22
- export const useReducedMotion = () => {
23
- if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
24
- return SSR_FALLBACK;
25
- }
26
- let media;
27
- try {
28
- media = window.matchMedia(REDUCED_MOTION_QUERY);
29
- }
30
- catch {
31
- return SSR_FALLBACK;
32
- }
33
- return readable(media.matches, (set) => {
34
- const handler = (event) => set(event.matches);
35
- if (typeof media.addEventListener === 'function') {
36
- media.addEventListener('change', handler);
37
- return () => media.removeEventListener('change', handler);
38
- }
39
- media.addListener(handler);
40
- return () => media.removeListener(handler);
41
- });
42
- };
@@ -1,39 +0,0 @@
1
- import { type Readable } from 'svelte/store';
2
- /**
3
- * Returns a copy of `keyframes` with transform-related keys removed when
4
- * `reduced` is `true`. Returns `keyframes` unchanged otherwise.
5
- *
6
- * The `transition` key is preserved so per-key transitions still flow through
7
- * to the animation engine.
8
- */
9
- export declare function filterReducedMotionKeyframes<T extends Record<string, unknown> | undefined>(keyframes: T, reduced: boolean): T;
10
- /**
11
- * Returns a readable store that reflects the resolved reduced-motion policy
12
- * for the current component subtree.
13
- *
14
- * Resolution combines the nearest `<MotionConfig reducedMotion>` ancestor with
15
- * the OS-level `prefers-reduced-motion` setting:
16
- *
17
- * - `'always'` → always `true`
18
- * - `'never'` (or no `<MotionConfig>` ancestor) → always `false`
19
- * - `'user'` → mirrors the OS preference, defaulting to `false` in SSR
20
- *
21
- * Use this from inside motion-aware components to decide whether to skip
22
- * transform animations.
23
- *
24
- * @returns {Readable<boolean>} `true` when descendant motion should be reduced.
25
- * @see https://motion.dev/docs/react-reduced-motion
26
- *
27
- * @example
28
- * ```svelte
29
- * <script>
30
- * import { useReducedMotionConfig } from '@humanspeak/svelte-motion'
31
- * const reduced = useReducedMotionConfig()
32
- * </script>
33
- *
34
- * {#if !$reduced}
35
- * <motion.div animate={{ x: 100 }} />
36
- * {/if}
37
- * ```
38
- */
39
- export declare const useReducedMotionConfig: () => Readable<boolean>;
@@ -1,92 +0,0 @@
1
- import { getMotionConfig } from '../components/motionConfig.context.js';
2
- import { useReducedMotion } from './reducedMotion.js';
3
- import { derived } from 'svelte/store';
4
- /**
5
- * CSS / motion property keys that move or rotate an element via `transform`.
6
- * When reduced motion is active these keys are stripped from animate keyframes
7
- * so the element stays in place while non-transform properties (opacity, color,
8
- * etc.) continue to animate.
9
- */
10
- const TRANSFORM_KEYS = new Set([
11
- 'x',
12
- 'y',
13
- 'z',
14
- 'translate',
15
- 'translateX',
16
- 'translateY',
17
- 'translateZ',
18
- 'scale',
19
- 'scaleX',
20
- 'scaleY',
21
- 'scaleZ',
22
- 'rotate',
23
- 'rotateX',
24
- 'rotateY',
25
- 'rotateZ',
26
- 'skew',
27
- 'skewX',
28
- 'skewY',
29
- 'transform',
30
- 'transformPerspective',
31
- 'perspective'
32
- ]);
33
- /**
34
- * Returns a copy of `keyframes` with transform-related keys removed when
35
- * `reduced` is `true`. Returns `keyframes` unchanged otherwise.
36
- *
37
- * The `transition` key is preserved so per-key transitions still flow through
38
- * to the animation engine.
39
- */
40
- export function filterReducedMotionKeyframes(keyframes, reduced) {
41
- if (!reduced || !keyframes)
42
- return keyframes;
43
- const out = {};
44
- for (const key of Object.keys(keyframes)) {
45
- if (!TRANSFORM_KEYS.has(key))
46
- out[key] = keyframes[key];
47
- }
48
- return out;
49
- }
50
- /**
51
- * Returns a readable store that reflects the resolved reduced-motion policy
52
- * for the current component subtree.
53
- *
54
- * Resolution combines the nearest `<MotionConfig reducedMotion>` ancestor with
55
- * the OS-level `prefers-reduced-motion` setting:
56
- *
57
- * - `'always'` → always `true`
58
- * - `'never'` (or no `<MotionConfig>` ancestor) → always `false`
59
- * - `'user'` → mirrors the OS preference, defaulting to `false` in SSR
60
- *
61
- * Use this from inside motion-aware components to decide whether to skip
62
- * transform animations.
63
- *
64
- * @returns {Readable<boolean>} `true` when descendant motion should be reduced.
65
- * @see https://motion.dev/docs/react-reduced-motion
66
- *
67
- * @example
68
- * ```svelte
69
- * <script>
70
- * import { useReducedMotionConfig } from '@humanspeak/svelte-motion'
71
- * const reduced = useReducedMotionConfig()
72
- * </script>
73
- *
74
- * {#if !$reduced}
75
- * <motion.div animate={{ x: 100 }} />
76
- * {/if}
77
- * ```
78
- */
79
- export const useReducedMotionConfig = () => {
80
- const motionConfig = getMotionConfig();
81
- // Read motionConfig?.reducedMotion *inside* the derived so dynamic
82
- // `<MotionConfig reducedMotion={...}>` updates surface to subscribers —
83
- // motionConfig uses property getters, so the value is always fresh.
84
- return derived(useReducedMotion(), ($osReduced) => {
85
- const policy = motionConfig?.reducedMotion ?? 'never';
86
- if (policy === 'always')
87
- return true;
88
- if (policy === 'never')
89
- return false;
90
- return $osReduced;
91
- });
92
- };