@humanspeak/svelte-motion 0.4.7 → 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.
@@ -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,43 @@
1
+ import { type BooleanSnapshot } from './booleanSnapshot.svelte.js';
2
+ /**
3
+ * State returned by {@link useReducedMotion}.
4
+ */
5
+ export type ReducedMotionState = BooleanSnapshot;
6
+ /**
7
+ * Returns a `{ current, subscribe }` object that reflects the user's
8
+ * `prefers-reduced-motion` setting. Mirrors framer-motion's
9
+ * `useReducedMotion` adapted for Svelte 5 runes.
10
+ *
11
+ * - `state.current` is reactive — read it in templates / `$derived` /
12
+ * `$effect` and it tracks the underlying `matchMedia` listener.
13
+ * - `state.subscribe(run)` is the Svelte readable store contract:
14
+ * synchronously emits the current value, then re-emits on every change.
15
+ * Kept for compat with downstream hooks that still consume Svelte
16
+ * readables until the Tier 2 wave lands.
17
+ *
18
+ * Diverges from React framer-motion's plain `boolean | null` return for
19
+ * the same reason as `useCycle`: a `$state`-backed value must live on an
20
+ * object so reads inside getters preserve tracking under runes.
21
+ *
22
+ * SSR-safe: returns a static `{ current: false }` when `window` /
23
+ * `matchMedia` is unavailable, including when `matchMedia` throws.
24
+ *
25
+ * The media listener is bound to the surrounding reactive scope via
26
+ * `$effect` — call this from a component `<script>` block (the standard
27
+ * hook contract). On unmount the listener is detached automatically.
28
+ *
29
+ * @returns A `ReducedMotionState` reflecting the OS reduced-motion setting.
30
+ * @see https://motion.dev/docs/react-reduced-motion
31
+ *
32
+ * @example
33
+ * ```svelte
34
+ * <script lang="ts">
35
+ * import { useReducedMotion } from '@humanspeak/svelte-motion'
36
+ *
37
+ * const reduced = useReducedMotion()
38
+ * </script>
39
+ *
40
+ * <div style:transform={reduced.current ? 'none' : 'rotate(45deg)'} />
41
+ * ```
42
+ */
43
+ export declare const useReducedMotion: () => ReducedMotionState;
@@ -0,0 +1,80 @@
1
+ import { createBooleanSnapshot } from './booleanSnapshot.svelte.js';
2
+ const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
3
+ /**
4
+ * Returns a `{ current, subscribe }` object that reflects the user's
5
+ * `prefers-reduced-motion` setting. Mirrors framer-motion's
6
+ * `useReducedMotion` adapted for Svelte 5 runes.
7
+ *
8
+ * - `state.current` is reactive — read it in templates / `$derived` /
9
+ * `$effect` and it tracks the underlying `matchMedia` listener.
10
+ * - `state.subscribe(run)` is the Svelte readable store contract:
11
+ * synchronously emits the current value, then re-emits on every change.
12
+ * Kept for compat with downstream hooks that still consume Svelte
13
+ * readables until the Tier 2 wave lands.
14
+ *
15
+ * Diverges from React framer-motion's plain `boolean | null` return for
16
+ * the same reason as `useCycle`: a `$state`-backed value must live on an
17
+ * object so reads inside getters preserve tracking under runes.
18
+ *
19
+ * SSR-safe: returns a static `{ current: false }` when `window` /
20
+ * `matchMedia` is unavailable, including when `matchMedia` throws.
21
+ *
22
+ * The media listener is bound to the surrounding reactive scope via
23
+ * `$effect` — call this from a component `<script>` block (the standard
24
+ * hook contract). On unmount the listener is detached automatically.
25
+ *
26
+ * @returns A `ReducedMotionState` reflecting the OS reduced-motion setting.
27
+ * @see https://motion.dev/docs/react-reduced-motion
28
+ *
29
+ * @example
30
+ * ```svelte
31
+ * <script lang="ts">
32
+ * import { useReducedMotion } from '@humanspeak/svelte-motion'
33
+ *
34
+ * const reduced = useReducedMotion()
35
+ * </script>
36
+ *
37
+ * <div style:transform={reduced.current ? 'none' : 'rotate(45deg)'} />
38
+ * ```
39
+ */
40
+ export const useReducedMotion = () => {
41
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
42
+ return staticState(false);
43
+ }
44
+ let media;
45
+ try {
46
+ media = window.matchMedia(REDUCED_MOTION_QUERY);
47
+ }
48
+ catch {
49
+ return staticState(false);
50
+ }
51
+ const [state, set] = createBooleanSnapshot(media.matches);
52
+ const handler = (event) => set(event.matches);
53
+ $effect(() => {
54
+ // Resync on (re-)mount in case the OS preference changed while
55
+ // the component was torn down between effect runs.
56
+ set(media.matches);
57
+ if (typeof media.addEventListener === 'function') {
58
+ media.addEventListener('change', handler);
59
+ return () => media.removeEventListener('change', handler);
60
+ }
61
+ // Safari < 14 / older WebKit — addListener/removeListener fallback.
62
+ media.addListener(handler);
63
+ return () => media.removeListener(handler);
64
+ });
65
+ return state;
66
+ };
67
+ /**
68
+ * SSR / no-matchMedia fallback. Static value, no listeners; `subscribe`
69
+ * is a one-shot sync emit + no-op unsubscribe so consumers can wire it
70
+ * the same way they do the live state.
71
+ */
72
+ const staticState = (value) => ({
73
+ get current() {
74
+ return value;
75
+ },
76
+ subscribe(run) {
77
+ run(value);
78
+ return () => undefined;
79
+ }
80
+ });
@@ -0,0 +1,74 @@
1
+ import { type ReducedMotionState } from './reducedMotion.svelte.js';
2
+ /**
3
+ * Returns a copy of `keyframes` with transform-related keys
4
+ * (`x`, `y`, `scale`, `rotate`, `skew`, `translate*`, etc.) removed
5
+ * when `reduced` is `true`. Returns `keyframes` unchanged otherwise.
6
+ *
7
+ * The `transition` key is preserved so per-key transitions still flow
8
+ * through to the animation engine; only the visual *targets* are
9
+ * stripped, not the timing config.
10
+ *
11
+ * @template T Keyframes record (or `undefined`).
12
+ * @param keyframes Source keyframes record — may be `undefined`, in
13
+ * which case the same `undefined` is returned regardless of
14
+ * `reduced`.
15
+ * @param reduced When `true`, strip transform keys; when `false`,
16
+ * return `keyframes` unchanged (by reference).
17
+ * @returns The original `keyframes` when `reduced` is `false` (same
18
+ * reference); otherwise a new record with transform keys filtered
19
+ * out and other keys preserved.
20
+ *
21
+ * @example
22
+ * ```ts
23
+ * filterReducedMotionKeyframes(
24
+ * { x: 100, scale: 1.2, opacity: 0.5 },
25
+ * true
26
+ * )
27
+ * // → { opacity: 0.5 }
28
+ *
29
+ * filterReducedMotionKeyframes(
30
+ * { x: 100, opacity: 0.5, transition: { duration: 0.3 } },
31
+ * true
32
+ * )
33
+ * // → { opacity: 0.5, transition: { duration: 0.3 } }
34
+ * ```
35
+ */
36
+ export declare const filterReducedMotionKeyframes: <T extends Record<string, unknown> | undefined>(keyframes: T, reduced: boolean) => T;
37
+ /**
38
+ * Returns a `{ current, subscribe }` object reflecting the resolved
39
+ * reduced-motion policy for the current component subtree.
40
+ *
41
+ * Resolution combines the nearest `<MotionConfig reducedMotion>` ancestor
42
+ * with the OS-level `prefers-reduced-motion` setting:
43
+ *
44
+ * - `'always'` → always `true`
45
+ * - `'never'` (or no `<MotionConfig>` ancestor) → always `false`
46
+ * - `'user'` → mirrors the OS preference, defaulting to `false` in SSR
47
+ *
48
+ * Both reactive read paths fire on **both** sources changing:
49
+ *
50
+ * - `.current` re-evaluates inside any reactive scope that reads it
51
+ * (templates, `$derived`, `$effect`) when either the OS preference *or*
52
+ * a parent `<MotionConfig reducedMotion={...}>` policy reassigns.
53
+ * - `.subscribe(run)` callbacks are driven by both the OS path
54
+ * (sync via `osReduced.subscribe`) and a `$effect` tracking the
55
+ * `motionConfig.reducedMotion` prop. Legacy store consumers see policy
56
+ * changes too — a fix vs. the prior `derived()`-based impl, which only
57
+ * re-fired on OS changes.
58
+ *
59
+ * @returns A `ReducedMotionState` reflecting the merged policy + OS setting.
60
+ * @see https://motion.dev/docs/react-reduced-motion
61
+ *
62
+ * @example
63
+ * ```svelte
64
+ * <script>
65
+ * import { useReducedMotionConfig } from '@humanspeak/svelte-motion'
66
+ * const reduced = useReducedMotionConfig()
67
+ * </script>
68
+ *
69
+ * {#if !reduced.current}
70
+ * <motion.div animate={{ x: 100 }} />
71
+ * {/if}
72
+ * ```
73
+ */
74
+ export declare const useReducedMotionConfig: () => ReducedMotionState;
@@ -0,0 +1,144 @@
1
+ import { getMotionConfig } from '../components/motionConfig.context.js';
2
+ import { createBooleanSnapshot } from './booleanSnapshot.svelte.js';
3
+ import { useReducedMotion } from './reducedMotion.svelte.js';
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
35
+ * (`x`, `y`, `scale`, `rotate`, `skew`, `translate*`, etc.) removed
36
+ * when `reduced` is `true`. Returns `keyframes` unchanged otherwise.
37
+ *
38
+ * The `transition` key is preserved so per-key transitions still flow
39
+ * through to the animation engine; only the visual *targets* are
40
+ * stripped, not the timing config.
41
+ *
42
+ * @template T Keyframes record (or `undefined`).
43
+ * @param keyframes Source keyframes record — may be `undefined`, in
44
+ * which case the same `undefined` is returned regardless of
45
+ * `reduced`.
46
+ * @param reduced When `true`, strip transform keys; when `false`,
47
+ * return `keyframes` unchanged (by reference).
48
+ * @returns The original `keyframes` when `reduced` is `false` (same
49
+ * reference); otherwise a new record with transform keys filtered
50
+ * out and other keys preserved.
51
+ *
52
+ * @example
53
+ * ```ts
54
+ * filterReducedMotionKeyframes(
55
+ * { x: 100, scale: 1.2, opacity: 0.5 },
56
+ * true
57
+ * )
58
+ * // → { opacity: 0.5 }
59
+ *
60
+ * filterReducedMotionKeyframes(
61
+ * { x: 100, opacity: 0.5, transition: { duration: 0.3 } },
62
+ * true
63
+ * )
64
+ * // → { opacity: 0.5, transition: { duration: 0.3 } }
65
+ * ```
66
+ */
67
+ export const filterReducedMotionKeyframes = (keyframes, reduced) => {
68
+ if (!reduced || !keyframes)
69
+ return keyframes;
70
+ const out = {};
71
+ for (const key of Object.keys(keyframes)) {
72
+ if (!TRANSFORM_KEYS.has(key))
73
+ out[key] = keyframes[key];
74
+ }
75
+ return out;
76
+ };
77
+ /**
78
+ * Returns a `{ current, subscribe }` object reflecting the resolved
79
+ * reduced-motion policy for the current component subtree.
80
+ *
81
+ * Resolution combines the nearest `<MotionConfig reducedMotion>` ancestor
82
+ * with the OS-level `prefers-reduced-motion` setting:
83
+ *
84
+ * - `'always'` → always `true`
85
+ * - `'never'` (or no `<MotionConfig>` ancestor) → always `false`
86
+ * - `'user'` → mirrors the OS preference, defaulting to `false` in SSR
87
+ *
88
+ * Both reactive read paths fire on **both** sources changing:
89
+ *
90
+ * - `.current` re-evaluates inside any reactive scope that reads it
91
+ * (templates, `$derived`, `$effect`) when either the OS preference *or*
92
+ * a parent `<MotionConfig reducedMotion={...}>` policy reassigns.
93
+ * - `.subscribe(run)` callbacks are driven by both the OS path
94
+ * (sync via `osReduced.subscribe`) and a `$effect` tracking the
95
+ * `motionConfig.reducedMotion` prop. Legacy store consumers see policy
96
+ * changes too — a fix vs. the prior `derived()`-based impl, which only
97
+ * re-fired on OS changes.
98
+ *
99
+ * @returns A `ReducedMotionState` reflecting the merged policy + OS setting.
100
+ * @see https://motion.dev/docs/react-reduced-motion
101
+ *
102
+ * @example
103
+ * ```svelte
104
+ * <script>
105
+ * import { useReducedMotionConfig } from '@humanspeak/svelte-motion'
106
+ * const reduced = useReducedMotionConfig()
107
+ * </script>
108
+ *
109
+ * {#if !reduced.current}
110
+ * <motion.div animate={{ x: 100 }} />
111
+ * {/if}
112
+ * ```
113
+ */
114
+ export const useReducedMotionConfig = () => {
115
+ const motionConfig = getMotionConfig();
116
+ const osReduced = useReducedMotion();
117
+ const resolve = () => {
118
+ const policy = motionConfig?.reducedMotion ?? 'never';
119
+ if (policy === 'always')
120
+ return true;
121
+ if (policy === 'never')
122
+ return false;
123
+ return osReduced.current;
124
+ };
125
+ const [state, set] = createBooleanSnapshot(resolve());
126
+ // Sync path: `osReduced.subscribe` fires the run callback on every OS
127
+ // preference change (and once synchronously on subscribe). The
128
+ // snapshot's same-value dedupe absorbs that initial duplicate emit.
129
+ const osUnsub = osReduced.subscribe(() => set(resolve()));
130
+ // Async path: `<MotionConfig reducedMotion>` is exposed via a
131
+ // property getter over the config component's prop, so reading it
132
+ // inside `$effect` tracks reassignments and fires the same `set` —
133
+ // closing the gap the legacy `derived()`-based impl had. Returning
134
+ // `osUnsub` installs it as the effect's cleanup, so the OS
135
+ // subscription is released on unmount.
136
+ $effect(() => {
137
+ // Void the read so the lint unused-expression rule doesn't fire
138
+ // on a deliberate dependency touch.
139
+ void motionConfig?.reducedMotion;
140
+ set(resolve());
141
+ return osUnsub;
142
+ });
143
+ return state;
144
+ };