@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.
- package/dist/html/_MotionContainer.svelte +8 -8
- package/dist/index.d.ts +5 -4
- package/dist/index.js +3 -3
- package/dist/utils/booleanSnapshot.svelte.d.ts +37 -0
- package/dist/utils/booleanSnapshot.svelte.js +48 -0
- package/dist/utils/inView.svelte.d.ts +209 -0
- package/dist/utils/inView.svelte.js +323 -0
- package/dist/utils/reducedMotion.svelte.d.ts +43 -0
- package/dist/utils/reducedMotion.svelte.js +80 -0
- package/dist/utils/reducedMotionConfig.svelte.d.ts +74 -0
- package/dist/utils/reducedMotionConfig.svelte.js +144 -0
- package/package.json +1 -1
- package/dist/utils/inView.d.ts +0 -136
- package/dist/utils/inView.js +0 -266
- package/dist/utils/reducedMotion.d.ts +0 -20
- package/dist/utils/reducedMotion.js +0 -42
- package/dist/utils/reducedMotionConfig.d.ts +0 -39
- package/dist/utils/reducedMotionConfig.js +0 -92
|
@@ -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
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@humanspeak/svelte-motion",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.9",
|
|
4
4
|
"description": "Framer Motion for Svelte 5. Declarative motion.<tag> components with AnimatePresence exit animations, gestures (hover, tap, drag, focus, in-view), variants, FLIP layout animations, shared-layout transitions, spring physics, and scroll-linked motion values. The drop-in Framer Motion alternative for Svelte and SvelteKit.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"svelte",
|