@humanspeak/svelte-motion 0.3.0 → 0.3.2

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.
@@ -6,15 +6,29 @@
6
6
  /**
7
7
  * Provide default Motion configuration to descendants.
8
8
  *
9
- * Wraps content and supplies defaults such as `transition` that are merged
10
- * with per-element props. Descendants can retrieve config via context.
9
+ * Wraps content and supplies defaults such as `transition` and
10
+ * `reducedMotion` that are merged with per-element props. Descendants can
11
+ * retrieve config via context.
11
12
  *
12
13
  * @prop transition Default `AnimationOptions` merged with element props.
14
+ * @prop reducedMotion Reduced-motion policy: `'user' | 'always' | 'never'`.
15
+ * Defaults to `'never'`.
13
16
  * @prop children Slotted content receiving this configuration.
14
17
  */
15
- let { transition, children }: MotionConfigProps & { children?: Snippet } = $props()
18
+ let { transition, reducedMotion, children }: MotionConfigProps & { children?: Snippet } =
19
+ $props()
16
20
 
17
- let motionConfig = $state<MotionConfigProps>({ transition })
21
+ // Use property getters so descendants always read the parent's current
22
+ // prop values — including remounted children inside `{#key}` blocks, which
23
+ // would otherwise see a stale snapshot if we cached the value in $state.
24
+ const motionConfig: MotionConfigProps = {
25
+ get transition() {
26
+ return transition
27
+ },
28
+ get reducedMotion() {
29
+ return reducedMotion
30
+ }
31
+ }
18
32
  createMotionConfig(motionConfig)
19
33
  </script>
20
34
 
@@ -5,6 +5,10 @@
5
5
 
6
6
  <script lang="ts">
7
7
  import { getMotionConfig } from '../components/motionConfig.context'
8
+ import {
9
+ filterReducedMotionKeyframes,
10
+ useReducedMotionConfig
11
+ } from '../utils/reducedMotionConfig'
8
12
  import type {
9
13
  MotionProps,
10
14
  MotionTransition,
@@ -50,7 +54,7 @@
50
54
  setInitialFalseContext,
51
55
  getInitialFalseContext
52
56
  } from '../components/variantContext.context'
53
- import { writable } from 'svelte/store'
57
+ import { get, writable } from 'svelte/store'
54
58
  import {
55
59
  transformSVGPathProperties,
56
60
  computeNormalizedSVGInitialAttrs,
@@ -115,6 +119,11 @@
115
119
  let isLoaded = $state<'mounting' | 'initial' | 'ready' | 'animated'>('mounting')
116
120
  let dataPath = $state<number>(-1)
117
121
  const motionConfig = $derived(getMotionConfig())
122
+ const reducedMotionStore = useReducedMotionConfig()
123
+ // Seed synchronously so the first render filters keyframes correctly —
124
+ // otherwise transforms could flash before the subscribe effect runs.
125
+ let reducedMotion = $state(get(reducedMotionStore))
126
+ $effect(() => reducedMotionStore.subscribe((value) => (reducedMotion = value)))
118
127
 
119
128
  // Get presence context to check if we're inside AnimatePresence
120
129
  const context = getAnimatePresenceContext()
@@ -219,10 +228,14 @@
219
228
  // Reactively update registration when element/exit/transition props change
220
229
  $effect(() => {
221
230
  if (element && context && resolvedExit) {
231
+ const filteredExit = filterReducedMotionKeyframes(
232
+ resolvedExit as Record<string, unknown>,
233
+ reducedMotion
234
+ )
222
235
  context.registerChild(
223
236
  presenceKey,
224
237
  element,
225
- resolvedExit,
238
+ filteredExit,
226
239
  mergedTransition as unknown as MotionTransition
227
240
  )
228
241
  }
@@ -350,7 +363,12 @@
350
363
  const resolvedExit = $derived(resolveExit(exitProp, variantsProp))
351
364
 
352
365
  // Extract keyframes from resolved initial, handling initial={false}
353
- const initialKeyframes = $derived(getInitialKeyframes(resolvedInitial))
366
+ const initialKeyframes = $derived(
367
+ filterReducedMotionKeyframes(
368
+ getInitialKeyframes(resolvedInitial) as Record<string, unknown>,
369
+ reducedMotion
370
+ )
371
+ )
354
372
 
355
373
  // Derived attributes to keep both branches in sync (focusability, data flags, style, class)
356
374
  const derivedAttrs = $derived<Record<string, unknown>>({
@@ -492,6 +510,13 @@
492
510
  payload as Record<string, unknown>
493
511
  ) as typeof payload
494
512
 
513
+ // Strip transform keys when reduced-motion is active so the element
514
+ // stays in place while opacity / color etc. still animate.
515
+ payload = filterReducedMotionKeyframes(
516
+ payload as Record<string, unknown>,
517
+ reducedMotion
518
+ ) as typeof payload
519
+
495
520
  // Ensure dash properties aren't pinned as inline styles
496
521
  if (element && (element as HTMLElement).style) {
497
522
  ;(element as HTMLElement).style.removeProperty('stroke-dasharray')
@@ -801,7 +826,10 @@
801
826
  try {
802
827
  // 1. Run exit animation if defined
803
828
  if (resolvedExit && element && !keyTransitionStopped) {
804
- const exitKeyframes = { ...(resolvedExit as Record<string, unknown>) }
829
+ const exitKeyframes = filterReducedMotionKeyframes(
830
+ { ...(resolvedExit as Record<string, unknown>) },
831
+ reducedMotion
832
+ )
805
833
  // Remove transition from keyframes (it's passed separately)
806
834
  delete exitKeyframes.transition
807
835
 
package/dist/index.d.ts CHANGED
@@ -4,14 +4,17 @@ export { motion } from './motion';
4
4
  export { animate, delay, hover, inView, press, resize, scroll, stagger, transform } from 'motion';
5
5
  export { anticipate, backIn, backInOut, backOut, circIn, circInOut, circOut, cubicBezier, easeIn, easeInOut, easeOut } from 'motion';
6
6
  export { clamp, distance, distance2D, interpolate, mix, pipe, progress, wrap } from 'motion';
7
- export type { DragAxis, DragConstraints, DragControls, DragInfo, DragTransition, MotionAnimate, MotionInitial, MotionOnDirectionLock, MotionOnDragTransitionEnd, MotionOnInViewEnd, MotionOnInViewStart, MotionTransition, MotionWhileDrag, MotionWhileFocus, MotionWhileHover, MotionWhileInView, MotionWhileTap, Variants } from './types';
7
+ export type { DragAxis, DragConstraints, DragControls, DragInfo, DragTransition, MotionAnimate, MotionInitial, MotionOnDirectionLock, MotionOnDragTransitionEnd, MotionOnInViewEnd, MotionOnInViewStart, MotionTransition, MotionWhileDrag, MotionWhileFocus, MotionWhileHover, MotionWhileInView, MotionWhileTap, ReducedMotionConfig, Variants } from './types';
8
8
  export { useAnimationFrame } from './utils/animationFrame';
9
+ export { useCycle } from './utils/cycle';
10
+ export type { Cycle, CycleState } from './utils/cycle';
9
11
  export { createDragControls } from './utils/dragControls';
10
12
  export { useMotionTemplate } from './utils/motionTemplate';
11
13
  export { useMotionValue } from './utils/motionValue';
12
14
  export type { MotionValue } from './utils/motionValue';
13
15
  export { useMotionValueEvent } from './utils/motionValueEvent';
14
16
  export { useReducedMotion } from './utils/reducedMotion';
17
+ export { useReducedMotionConfig } from './utils/reducedMotionConfig';
15
18
  export { useScroll } from './utils/scroll';
16
19
  export { useSpring } from './utils/spring';
17
20
  export { useVelocity } from './utils/velocity';
package/dist/index.js CHANGED
@@ -8,11 +8,13 @@ export { anticipate, backIn, backInOut, backOut, circIn, circInOut, circOut, cub
8
8
  // Re-export utility functions
9
9
  export { clamp, distance, distance2D, interpolate, mix, pipe, progress, wrap } from 'motion';
10
10
  export { useAnimationFrame } from './utils/animationFrame';
11
+ export { useCycle } from './utils/cycle';
11
12
  export { createDragControls } from './utils/dragControls';
12
13
  export { useMotionTemplate } from './utils/motionTemplate';
13
14
  export { useMotionValue } from './utils/motionValue';
14
15
  export { useMotionValueEvent } from './utils/motionValueEvent';
15
16
  export { useReducedMotion } from './utils/reducedMotion';
17
+ export { useReducedMotionConfig } from './utils/reducedMotionConfig';
16
18
  export { useScroll } from './utils/scroll';
17
19
  export { useSpring } from './utils/spring';
18
20
  export { useVelocity } from './utils/velocity';
package/dist/types.d.ts CHANGED
@@ -323,9 +323,27 @@ export type MotionProps = {
323
323
  * - delay: Time to wait before starting the animation
324
324
  * - repeat: Number of times to repeat the animation
325
325
  */
326
+ /**
327
+ * Reduced-motion policy for {@link MotionConfigProps.reducedMotion}.
328
+ *
329
+ * - `'never'` (default): Animations run as authored, regardless of OS preference.
330
+ * - `'always'`: Transform animations (x, y, scale, rotate, skew, translate) are
331
+ * skipped. Other properties such as `opacity` and `color` still animate.
332
+ * - `'user'`: Honors the OS-level `prefers-reduced-motion: reduce` setting —
333
+ * behaves like `'always'` when the user has opted in, otherwise `'never'`.
334
+ *
335
+ * @see https://motion.dev/docs/react-reduced-motion
336
+ */
337
+ export type ReducedMotionConfig = 'user' | 'always' | 'never';
326
338
  export type MotionConfigProps = {
327
339
  /** Animation configuration */
328
340
  transition?: MotionTransition;
341
+ /**
342
+ * Reduced-motion policy applied to descendant motion elements.
343
+ *
344
+ * Defaults to `'never'`. See {@link ReducedMotionConfig}.
345
+ */
346
+ reducedMotion?: ReducedMotionConfig;
329
347
  };
330
348
  /**
331
349
  * AnimatePresence mode controls how enter and exit animations are coordinated.
@@ -0,0 +1,35 @@
1
+ import { type Readable } from 'svelte/store';
2
+ export type Cycle = (next?: number) => void;
3
+ export type CycleState<T> = [Readable<T>, Cycle];
4
+ /**
5
+ * Cycles through a series of values. Mirrors Framer Motion's `useCycle`.
6
+ *
7
+ * Returns a tuple `[value, cycle]`:
8
+ *
9
+ * - `value` is a Svelte readable store of the current item; subscribe with
10
+ * `$value` in templates.
11
+ * - `cycle()` advances to the next item, wrapping back to index `0` when it
12
+ * passes the end.
13
+ * - `cycle(i)` jumps to the item at index `i`. The index is taken as-is to
14
+ * match `framer-motion` &mdash; out-of-range values yield `items[i]`, which
15
+ * may be `undefined`.
16
+ *
17
+ * Calls that resolve to the current index are no-ops and do not notify
18
+ * subscribers, matching React `useState`'s `Object.is` bail-out semantics.
19
+ *
20
+ * @param items - Items to cycle through. Must include at least one item.
21
+ * @returns A `[Readable<T>, Cycle]` tuple.
22
+ * @see https://motion.dev/docs/react-use-cycle
23
+ *
24
+ * @example
25
+ * ```svelte
26
+ * <script>
27
+ * import { motion, useCycle } from '@humanspeak/svelte-motion'
28
+ *
29
+ * const [x, cycleX] = useCycle(0, 50, 100)
30
+ * </script>
31
+ *
32
+ * <motion.div animate={{ x: $x }} onclick={() => cycleX()} />
33
+ * ```
34
+ */
35
+ export declare const useCycle: <T>(...items: T[]) => CycleState<T>;
@@ -0,0 +1,48 @@
1
+ import { wrap } from 'motion';
2
+ import { writable } from 'svelte/store';
3
+ /**
4
+ * Cycles through a series of values. Mirrors Framer Motion's `useCycle`.
5
+ *
6
+ * Returns a tuple `[value, cycle]`:
7
+ *
8
+ * - `value` is a Svelte readable store of the current item; subscribe with
9
+ * `$value` in templates.
10
+ * - `cycle()` advances to the next item, wrapping back to index `0` when it
11
+ * passes the end.
12
+ * - `cycle(i)` jumps to the item at index `i`. The index is taken as-is to
13
+ * match `framer-motion` &mdash; out-of-range values yield `items[i]`, which
14
+ * may be `undefined`.
15
+ *
16
+ * Calls that resolve to the current index are no-ops and do not notify
17
+ * subscribers, matching React `useState`'s `Object.is` bail-out semantics.
18
+ *
19
+ * @param items - Items to cycle through. Must include at least one item.
20
+ * @returns A `[Readable<T>, Cycle]` tuple.
21
+ * @see https://motion.dev/docs/react-use-cycle
22
+ *
23
+ * @example
24
+ * ```svelte
25
+ * <script>
26
+ * import { motion, useCycle } from '@humanspeak/svelte-motion'
27
+ *
28
+ * const [x, cycleX] = useCycle(0, 50, 100)
29
+ * </script>
30
+ *
31
+ * <motion.div animate={{ x: $x }} onclick={() => cycleX()} />
32
+ * ```
33
+ */
34
+ export const useCycle = (...items) => {
35
+ if (items.length === 0) {
36
+ throw new Error('useCycle requires at least one item');
37
+ }
38
+ let index = 0;
39
+ const store = writable(items[0]);
40
+ const cycle = (next) => {
41
+ const target = typeof next === 'number' ? next : wrap(0, items.length, index + 1);
42
+ if (target === index)
43
+ return;
44
+ index = target;
45
+ store.set(items[target]);
46
+ };
47
+ return [{ subscribe: store.subscribe }, cycle];
48
+ };
@@ -0,0 +1,39 @@
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>;
@@ -0,0 +1,92 @@
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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanspeak/svelte-motion",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "A Framer Motion-compatible animation library for Svelte 5 that provides smooth, hardware-accelerated animations. Features include spring physics, custom easing, and fluid transitions. Built on top of the motion library, it offers a simple API for creating complex animations with minimal code. Perfect for interactive UIs, micro-interactions, and engaging user experiences.",
5
5
  "keywords": [
6
6
  "svelte",