@humanspeak/svelte-motion 0.1.2 → 0.1.3

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/README.md CHANGED
@@ -74,15 +74,16 @@ This package carefully selects its dependencies to provide a robust and maintain
74
74
 
75
75
  ### Examples
76
76
 
77
- | Motion | Demo / Route | REPL |
78
- | -------------------------------------------------------------------------------------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------- |
79
- | [React - Enter Animation](https://examples.motion.dev/react/enter-animation) | `/tests/motion/enter-animation` | [View Example](https://svelte.dev/playground/7f60c347729f4ea48b1a4590c9dedc02?version=5.38.10) |
80
- | [HTML Content (0→100 counter)](https://examples.motion.dev/react/html-content) | `/tests/motion/html-content` | [View Example](https://svelte.dev/playground/31cd72df4a3242b4b4589501a25e774f?version=5.38.10) |
81
- | [Aspect Ratio](https://examples.motion.dev/react/aspect-ratio) | `/tests/motion/aspect-ratio` | [View Example](https://svelte.dev/playground/1bf60e745fae44f5becb4c830fde9b6e?version=5.38.10) |
82
- | [Hover + Tap (whileHover + whileTap)](https://motion.dev/docs/react?platform=react#hover-tap-animation) | `/tests/motion/hover-and-tap` | [View Example](https://svelte.dev/playground/674c7d58f2c740baa4886b01340a97ea?version=5.38.10) |
83
- | [Random - Shiny Button](https://www.youtube.com/watch?v=jcpLprT5F0I) by [@verse\_](https://x.com/verse_) | `/tests/random/shiny-button` | [View Example](https://svelte.dev/playground/96f9e0bf624f4396adaf06c519147450?version=5.38.10) |
84
- | [Fancy Like Button](https://github.com/DRlFTER/fancyLikeButton) | `/tests/random/fancy-like-button` | [View Example](https://svelte.dev/playground/c34b7e53d41c48b0ab1eaf21ca120c6e?version=5.38.10) |
85
- | [Keyframes (square → circle → square; scale 1→2→1)](https://motion.dev/docs/react-animation#keyframes) | `/tests/motion/keyframes` | [View Example](https://svelte.dev/playground/05595ce0db124c1cbbe4e74fda68d717?version=5.38.10) |
77
+ | Motion | Demo / Route | REPL |
78
+ | -------------------------------------------------------------------------------------------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------- |
79
+ | [React - Enter Animation](https://examples.motion.dev/react/enter-animation) | `/tests/motion/enter-animation` | [View Example](https://svelte.dev/playground/7f60c347729f4ea48b1a4590c9dedc02?version=5.38.10) |
80
+ | [HTML Content (0→100 counter)](https://examples.motion.dev/react/html-content) | `/tests/motion/html-content` | [View Example](https://svelte.dev/playground/31cd72df4a3242b4b4589501a25e774f?version=5.38.10) |
81
+ | [Aspect Ratio](https://examples.motion.dev/react/aspect-ratio) | `/tests/motion/aspect-ratio` | [View Example](https://svelte.dev/playground/1bf60e745fae44f5becb4c830fde9b6e?version=5.38.10) |
82
+ | [Hover + Tap (whileHover + whileTap)](https://motion.dev/docs/react?platform=react#hover-tap-animation) | `/tests/motion/hover-and-tap` | [View Example](https://svelte.dev/playground/674c7d58f2c740baa4886b01340a97ea?version=5.38.10) |
83
+ | [Random - Shiny Button](https://www.youtube.com/watch?v=jcpLprT5F0I) by [@verse\_](https://x.com/verse_) | `/tests/random/shiny-button` | [View Example](https://svelte.dev/playground/96f9e0bf624f4396adaf06c519147450?version=5.38.10) |
84
+ | [Fancy Like Button](https://github.com/DRlFTER/fancyLikeButton) | `/tests/random/fancy-like-button` | [View Example](https://svelte.dev/playground/c34b7e53d41c48b0ab1eaf21ca120c6e?version=5.38.10) |
85
+ | [Keyframes (square → circle → square; scale 1→2→1)](https://motion.dev/docs/react-animation#keyframes) | `/tests/motion/keyframes` | [View Example](https://svelte.dev/playground/05595ce0db124c1cbbe4e74fda68d717?version=5.38.10) |
86
+ | [Animated Border Gradient (conic-gradient rotate)](https://www.youtube.com/watch?v=OgQI1-9T6ZA) | `/tests/random/animated-border-gradient` | [View Example](https://svelte.dev/playground/6983a61b4c35441b8aa72a971de01a23?version=5.38.10) |
86
87
 
87
88
  ## Interactions
88
89
 
@@ -149,6 +150,91 @@ Notes:
149
150
  - Transform properties like `scale`/`rotate` are composed into a single `transform` style during SSR.
150
151
  - When `initial` is empty, the first keyframe from `animate` is used to seed SSR styles.
151
152
 
153
+ ## Utilities
154
+
155
+ ### useTime(id?)
156
+
157
+ - Returns a Svelte readable store that updates once per animation frame with elapsed milliseconds since creation.
158
+ - If you pass an `id`, calls with the same id return a shared timeline (kept in sync across components).
159
+ - SSR-safe: Returns a static `0` store when `window` is not available.
160
+
161
+ ```svelte
162
+ <script lang="ts">
163
+ import { motion, useTime } from '$lib'
164
+ import { derived } from 'svelte/store'
165
+
166
+ const time = useTime('global') // shared
167
+ const rotate = derived(time, (t) => ((t % 4000) / 4000) * 360)
168
+ </script>
169
+
170
+ <motion.div style={`rotate: ${$rotate}deg`} />
171
+ ```
172
+
173
+ ### useSpring
174
+
175
+ `useSpring` creates a readable store that animates to its latest target with a spring. You can either control it directly with `set`/`jump`, or have it follow another readable (like a time-derived value).
176
+
177
+ ```svelte
178
+ <script lang="ts">
179
+ import { useTime, useTransform, useSpring } from '$lib'
180
+
181
+ // Track another readable
182
+ const time = useTime()
183
+ const blurTarget = useTransform(() => {
184
+ const phase = ($time % 2000) / 2000
185
+ return 4 * (0.5 + 0.5 * Math.sin(phase * Math.PI * 2)) // 0..4
186
+ }, [time])
187
+ const blur = useSpring(blurTarget, { stiffness: 300 })
188
+
189
+ // Or direct control
190
+ const x = useSpring(0, { stiffness: 300 })
191
+ // x.set(100) // animates to 100
192
+ // x.jump(0) // jumps without animation
193
+ </script>
194
+
195
+ <div style={`filter: blur(${$blur}px)`} />
196
+ ```
197
+
198
+ - Accepts number or unit string (e.g., `"100vh"`) or a readable source.
199
+ - Returns a readable with `{ set, jump }` methods when used in the browser; SSR-safe on the server.
200
+ - Reference: Motion useSpring docs [motion.dev](https://motion.dev/docs/react-use-spring?platform=react).
201
+
202
+ ### useTransform
203
+
204
+ `useTransform` creates a derived readable. It supports:
205
+
206
+ - Range mapping: map a numeric source across input/output ranges with optional `{ clamp, ease, mixer }`.
207
+ - Function form: compute from one or more dependencies.
208
+
209
+ Range mapping example:
210
+
211
+ ```svelte
212
+ <script lang="ts">
213
+ import { useTime, useTransform } from '$lib'
214
+ const time = useTime()
215
+ // Map 0..4000ms to 0..360deg, unclamped to allow wrap-around
216
+ const rotate = useTransform(time, [0, 4000], [0, 360], { clamp: false })
217
+ </script>
218
+
219
+ <div style={`rotate: ${$rotate}deg`} />
220
+ ```
221
+
222
+ Function form example:
223
+
224
+ ```svelte
225
+ <script lang="ts">
226
+ import { useTransform } from '$lib'
227
+ // Given stores a and b, compute their sum
228
+ const add = (a: number, b: number) => a + b
229
+ // deps are stores; body can access them via $ syntax
230
+ const total = useTransform(() => add($a, $b), [a, b])
231
+ </script>
232
+
233
+ <span>{$total}</span>
234
+ ```
235
+
236
+ - Reference: Motion useTransform docs [motion.dev](https://motion.dev/docs/react-use-transform?platform=react).
237
+
152
238
  ## Access the underlying element (bind:ref)
153
239
 
154
240
  You can bind a ref to access the underlying DOM element rendered by a motion component:
package/dist/index.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import MotionConfig from './components/MotionConfig.svelte';
2
- import type { MotionComponents } from './html/index.js';
2
+ import type { MotionComponents } from './html/index';
3
3
  export declare const motion: MotionComponents;
4
4
  export { animate, hover } from 'motion';
5
- export type { MotionAnimate, MotionInitial, MotionTransition, MotionWhileTap } from './types.js';
5
+ export type { MotionAnimate, MotionInitial, MotionTransition, MotionWhileTap } from './types';
6
+ export { useSpring } from './utils/spring';
7
+ export { useTime } from './utils/time';
8
+ export { useTransform } from './utils/transform';
6
9
  export { MotionConfig };
package/dist/index.js CHANGED
@@ -1,7 +1,10 @@
1
1
  import MotionConfig from './components/MotionConfig.svelte';
2
- import * as html from './html/index.js';
2
+ import * as html from './html/index';
3
3
  // Create the motion object with all components
4
4
  export const motion = Object.fromEntries(Object.entries(html).map(([key, component]) => [key.toLowerCase(), component]));
5
5
  // Export all types
6
6
  export { animate, hover } from 'motion';
7
+ export { useSpring } from './utils/spring';
8
+ export { useTime } from './utils/time';
9
+ export { useTransform } from './utils/transform';
7
10
  export { MotionConfig };
@@ -1,4 +1,4 @@
1
- import type { AnimationOptions, DOMKeyframesDefinition } from 'motion';
1
+ import { type AnimationOptions, type DOMKeyframesDefinition } from 'motion';
2
2
  /**
3
3
  * Merge two Motion `AnimationOptions` objects without mutating the inputs.
4
4
  *
@@ -1,5 +1,5 @@
1
+ import { hasFinishedPromise, isPromiseLike } from './promise';
1
2
  import { animate } from 'motion';
2
- import { hasFinishedPromise, isPromiseLike } from './promise.js';
3
3
  /**
4
4
  * Merge two Motion `AnimationOptions` objects without mutating the inputs.
5
5
  *
@@ -1,4 +1,4 @@
1
- import type { AnimationOptions } from 'motion';
1
+ import { type AnimationOptions } from 'motion';
2
2
  /**
3
3
  * Determine whether the current environment supports true hover.
4
4
  *
@@ -0,0 +1,38 @@
1
+ import { type Readable } from 'svelte/store';
2
+ /**
3
+ * Spring configuration options.
4
+ *
5
+ * This is a minimal subset modeled after Motion's spring transition options.
6
+ * Values are tuned for sensible defaults, not parity.
7
+ *
8
+ * @typedef {Object} SpringOptions
9
+ * @property {number=} stiffness Spring stiffness (higher = snappier). Default 170.
10
+ * @property {number=} damping Spring damping (higher = less oscillation). Default 26.
11
+ * @property {number=} mass Mass of the object. Default 1.
12
+ * @property {number=} restDelta Threshold for absolute position delta to stop. Default 0.01.
13
+ * @property {number=} restSpeed Threshold for velocity magnitude to stop. Default 0.01.
14
+ */
15
+ export type SpringOptions = {
16
+ stiffness?: number;
17
+ damping?: number;
18
+ mass?: number;
19
+ restDelta?: number;
20
+ restSpeed?: number;
21
+ };
22
+ /**
23
+ * Creates a spring-animated readable store. The store exposes `set` to
24
+ * animate towards a target, or `jump` to immediately set the value without
25
+ * animation. When constructed with another readable store, the spring
26
+ * automatically follows it.
27
+ *
28
+ * This is SSR-safe: On the server it returns a static store and no timers run.
29
+ *
30
+ * @template T
31
+ * @param {number|string|Readable<number|string>} source Initial value or a source store to follow.
32
+ * @param {SpringOptions=} options Spring configuration.
33
+ * @returns {Readable<number|string> & { set: (v: number|string) => void; jump: (v: number|string) => void; }}
34
+ */
35
+ export declare const useSpring: (source: number | string | Readable<number | string>, options?: SpringOptions) => Readable<number | string> & {
36
+ set: (v: number | string) => void;
37
+ jump: (v: number | string) => void;
38
+ };
@@ -0,0 +1,157 @@
1
+ import { readable, writable } from 'svelte/store';
2
+ /**
3
+ * Parses a number or unit string into numeric value and unit.
4
+ * @param {number|string} v The input value.
5
+ * @returns {UnitValue} Parsed value and unit.
6
+ * @private
7
+ */
8
+ const parseUnit = (v) => {
9
+ if (typeof v === 'number')
10
+ return { value: v, unit: '' };
11
+ const match = String(v).match(/^(-?\d*\.?\d+)(.*)$/);
12
+ if (!match || !match[1])
13
+ return { value: 0, unit: '' };
14
+ const parsed = Number.parseFloat(match[1]);
15
+ if (!Number.isFinite(parsed))
16
+ return { value: 0, unit: '' };
17
+ const unit = match[2] ?? '';
18
+ return { value: parsed, unit };
19
+ };
20
+ /**
21
+ * Formats a numeric value with a unit.
22
+ * @param {number} n Numeric value.
23
+ * @param {string} unit Unit suffix.
24
+ * @returns {number|string} Number or string with unit.
25
+ * @private
26
+ */
27
+ const formatUnit = (n, unit) => (unit ? `${n}${unit}` : n);
28
+ /**
29
+ * Creates a spring-animated readable store. The store exposes `set` to
30
+ * animate towards a target, or `jump` to immediately set the value without
31
+ * animation. When constructed with another readable store, the spring
32
+ * automatically follows it.
33
+ *
34
+ * This is SSR-safe: On the server it returns a static store and no timers run.
35
+ *
36
+ * @template T
37
+ * @param {number|string|Readable<number|string>} source Initial value or a source store to follow.
38
+ * @param {SpringOptions=} options Spring configuration.
39
+ * @returns {Readable<number|string> & { set: (v: number|string) => void; jump: (v: number|string) => void; }}
40
+ */
41
+ export const useSpring = (source, options = {}) => {
42
+ if (typeof window === 'undefined') {
43
+ // Derive best-effort initial value for SSR to avoid hydration mismatch
44
+ let initial = 0;
45
+ if (typeof source === 'number' || typeof source === 'string') {
46
+ initial = source;
47
+ }
48
+ else if (source && typeof source === 'object') {
49
+ const anySource = source;
50
+ if (typeof anySource.get === 'function') {
51
+ const v = anySource.get();
52
+ if (typeof v === 'number' || typeof v === 'string')
53
+ initial = v;
54
+ }
55
+ else if (typeof anySource.value === 'number' || typeof anySource.value === 'string') {
56
+ initial = anySource.value;
57
+ }
58
+ }
59
+ const store = readable(initial, () => { });
60
+ store.set = () => { };
61
+ store.jump = () => { };
62
+ return store;
63
+ }
64
+ const { stiffness = 170, damping = 26, mass = 1, restDelta = 0.01, restSpeed = 0.01 } = options;
65
+ const state = {
66
+ current: parseUnit(typeof source === 'object' ? 0 : source),
67
+ target: parseUnit(typeof source === 'object' ? 0 : source)
68
+ };
69
+ const unit = state.current.unit || state.target.unit;
70
+ const store = writable(formatUnit(state.current.value, unit));
71
+ let raf = 0;
72
+ let lastTime = 0;
73
+ let velocity = 0;
74
+ const step = (t) => {
75
+ if (!lastTime)
76
+ lastTime = t;
77
+ // Clamp dt to a safe range to avoid instability across large time gaps
78
+ const dt = Math.min(0.1, Math.max(0.001, (t - lastTime) / 1000));
79
+ lastTime = t;
80
+ const displacement = state.current.value - state.target.value;
81
+ // Spring force based on Hooke's Law: F = -k x; damping force: -c v
82
+ const spring = -stiffness * displacement;
83
+ const damper = -damping * velocity;
84
+ const accel = (spring + damper) / mass;
85
+ velocity += accel * dt;
86
+ state.current.value += velocity * dt;
87
+ const isNoVelocity = Math.abs(velocity) <= restSpeed;
88
+ const isNoDisplacement = Math.abs(state.current.value - state.target.value) <= restDelta;
89
+ const done = isNoVelocity && isNoDisplacement;
90
+ if (done) {
91
+ state.current.value = state.target.value;
92
+ store.set(formatUnit(state.current.value, unit));
93
+ raf = 0;
94
+ lastTime = 0;
95
+ return;
96
+ }
97
+ store.set(formatUnit(state.current.value, unit));
98
+ raf = requestAnimationFrame(step);
99
+ };
100
+ const start = () => {
101
+ if (raf)
102
+ return;
103
+ raf = requestAnimationFrame(step);
104
+ };
105
+ const api = {
106
+ set: (v) => {
107
+ state.target = parseUnit(v);
108
+ start();
109
+ },
110
+ jump: (v) => {
111
+ state.current = parseUnit(v);
112
+ state.target = parseUnit(v);
113
+ velocity = 0;
114
+ store.set(formatUnit(state.current.value, state.current.unit || state.target.unit));
115
+ }
116
+ };
117
+ // If following another store, subscribe and forward values to set()
118
+ if (typeof source === 'object' && 'subscribe' in source) {
119
+ let followSource = true;
120
+ const unsub = source.subscribe((v) => api.set(v));
121
+ const wrapped = readable(formatUnit(state.current.value, unit), (set) => {
122
+ const sub = store.subscribe(set);
123
+ return () => {
124
+ sub();
125
+ unsub();
126
+ followSource = false;
127
+ if (raf)
128
+ cancelAnimationFrame(raf);
129
+ };
130
+ });
131
+ wrapped.set = (v) => {
132
+ if (followSource)
133
+ unsub();
134
+ followSource = false;
135
+ api.set(v);
136
+ };
137
+ wrapped.jump = (v) => {
138
+ if (followSource)
139
+ unsub();
140
+ followSource = false;
141
+ api.jump(v);
142
+ };
143
+ return wrapped;
144
+ }
145
+ // Standard readable wrapping internal writable
146
+ const wrapped = readable(formatUnit(state.current.value, unit), (set) => {
147
+ const sub = store.subscribe(set);
148
+ return () => {
149
+ sub();
150
+ if (raf)
151
+ cancelAnimationFrame(raf);
152
+ };
153
+ });
154
+ wrapped.set = api.set;
155
+ wrapped.jump = api.jump;
156
+ return wrapped;
157
+ };
@@ -0,0 +1,14 @@
1
+ import { type Readable } from 'svelte/store';
2
+ /**
3
+ * Returns a time store that ticks once per animation frame.
4
+ *
5
+ * - Without an `id`, returns a fresh timeline per call.
6
+ * - With an `id`, callers sharing the same id receive the same store/timeline,
7
+ * ensuring synchronized reads across components.
8
+ * - SSR-safe: Returns a static 0-valued store when `window` is unavailable.
9
+ *
10
+ * @param {string=} id Optional timeline identifier for sharing across calls.
11
+ * @returns {Readable<number>} A readable store of elapsed milliseconds.
12
+ * @see https://motion.dev/docs/react-use-time?platform=react
13
+ */
14
+ export declare const useTime: (id?: string) => Readable<number>;
@@ -0,0 +1,68 @@
1
+ import { readable } from 'svelte/store';
2
+ const SSR_ZERO = readable(0, () => { });
3
+ const sharedStores = new Map();
4
+ // Clear shared timelines on HMR dispose to avoid stale entries across hot reloads
5
+ if (import.meta &&
6
+ import.meta.hot) {
7
+ ;
8
+ import.meta.hot.dispose(() => {
9
+ sharedStores.clear();
10
+ });
11
+ }
12
+ /**
13
+ * Creates a new time store that updates once per animation frame.
14
+ *
15
+ * The store value represents elapsed milliseconds since the store was created.
16
+ * In SSR environments (no `window`), a static 0-valued store is returned.
17
+ *
18
+ * @returns {Readable<number>} A readable store of elapsed milliseconds.
19
+ * @see https://motion.dev/docs/react-use-time?platform=react
20
+ * @private
21
+ */
22
+ const createTimeStore = () => {
23
+ if (typeof window === 'undefined')
24
+ return SSR_ZERO;
25
+ return readable(0, (set) => {
26
+ const start = performance.now();
27
+ let raf = 0;
28
+ /* c8 ignore start */
29
+ const loop = (t) => {
30
+ set(t - start);
31
+ raf = requestAnimationFrame(loop);
32
+ };
33
+ /* c8 ignore stop */
34
+ raf = requestAnimationFrame(loop);
35
+ return () => cancelAnimationFrame(raf);
36
+ });
37
+ };
38
+ /**
39
+ * Returns a time store that ticks once per animation frame.
40
+ *
41
+ * - Without an `id`, returns a fresh timeline per call.
42
+ * - With an `id`, callers sharing the same id receive the same store/timeline,
43
+ * ensuring synchronized reads across components.
44
+ * - SSR-safe: Returns a static 0-valued store when `window` is unavailable.
45
+ *
46
+ * @param {string=} id Optional timeline identifier for sharing across calls.
47
+ * @returns {Readable<number>} A readable store of elapsed milliseconds.
48
+ * @see https://motion.dev/docs/react-use-time?platform=react
49
+ */
50
+ export const useTime = (id) => {
51
+ if (!id)
52
+ return createTimeStore();
53
+ if (typeof window === 'undefined')
54
+ return SSR_ZERO;
55
+ const existing = sharedStores.get(id);
56
+ if (existing)
57
+ return existing;
58
+ const base = createTimeStore();
59
+ const store = readable(0, (set) => {
60
+ const unsub = base.subscribe(set);
61
+ return () => {
62
+ unsub();
63
+ sharedStores.delete(id);
64
+ };
65
+ });
66
+ sharedStores.set(id, store);
67
+ return store;
68
+ };
@@ -0,0 +1,42 @@
1
+ import { type Readable } from 'svelte/store';
2
+ /**
3
+ * Options for range-mapping transform.
4
+ *
5
+ * - clamp: If true, clamps the input to the active segment bounds.
6
+ * - ease: A single easing function or one per segment to shape interpolation.
7
+ * - mixer: Custom mixer factory to interpolate non-numeric outputs.
8
+ *
9
+ * @see https://motion.dev/docs/react-use-transform?platform=react
10
+ */
11
+ export type TransformOptions = {
12
+ clamp?: boolean;
13
+ ease?: ((t: number) => number) | Array<(t: number) => number>;
14
+ mixer?: (from: unknown, to: unknown) => (t: number) => unknown;
15
+ };
16
+ /**
17
+ * Clamps a numeric value between two bounds, irrespective of their order.
18
+ *
19
+ * @param val Current value.
20
+ * @param a First bound.
21
+ * @param b Second bound.
22
+ * @returns Value clamped to [min(a,b), max(a,b)].
23
+ */
24
+ export declare const clampBidirectional: (val: number, a: number, b: number) => number;
25
+ /**
26
+ * Creates a derived Svelte store that transforms values.
27
+ *
28
+ * Two supported forms (API parity with Motion's useTransform):
29
+ * - Mapping form: Map a numeric source across input/output ranges.
30
+ * Example: `useTransform(src, [0, 100], [0, 1], { clamp: true })`
31
+ * - Function form: Recompute from a function based on dependency stores.
32
+ * Example: `useTransform(() => compute(), [depA, depB])`
33
+ *
34
+ * @template T
35
+ * @param {Readable<number>|(() => T)} sourceOrCompute Numeric source store (mapping form), or compute function (function form).
36
+ * @param {number[]|Readable<unknown>[]} inputOrDeps Input stops (mapping) or dependency stores (function form).
37
+ * @param {T[]=} output Output stops (mapping form only). Must match input length.
38
+ * @param {TransformOptions=} options Mapping options (mapping form only).
39
+ * @returns {Readable<T>} A derived Svelte readable store.
40
+ * @see https://motion.dev/docs/react-use-transform?platform=react
41
+ */
42
+ export declare const useTransform: <T = number>(sourceOrCompute: Readable<number> | (() => T), inputOrDeps: number[] | Readable<unknown>[], output?: T[], options?: TransformOptions) => Readable<T>;
@@ -0,0 +1,129 @@
1
+ import { derived, readable } from 'svelte/store';
2
+ /**
3
+ * Creates a linear mixer function for numeric values.
4
+ *
5
+ * @param from Starting numeric value.
6
+ * @param to Ending numeric value.
7
+ * @returns Function that linearly interpolates between from→to for progress t∈[0,1].
8
+ * @private
9
+ */
10
+ const linearMix = (from, to) => (t) => from + (to - from) * t;
11
+ /**
12
+ * Clamps a numeric value between two bounds, irrespective of their order.
13
+ *
14
+ * @param val Current value.
15
+ * @param a First bound.
16
+ * @param b Second bound.
17
+ * @returns Value clamped to [min(a,b), max(a,b)].
18
+ */
19
+ export const clampBidirectional = (val, a, b) => {
20
+ const lower = a < b ? a : b;
21
+ const upper = a < b ? b : a;
22
+ return Math.min(Math.max(val, lower), upper);
23
+ };
24
+ /**
25
+ * Finds the segment index i such that x lies between input[i] and input[i+1].
26
+ * Handles both ascending and descending input ranges.
27
+ *
28
+ * @param input Monotonic list of input stops.
29
+ * @param x Current input value.
30
+ * @returns Segment index in range [0, input.length - 2].
31
+ * @private
32
+ */
33
+ const findSegment = (input, x) => {
34
+ if (input.length < 2)
35
+ return 0;
36
+ const first = input[0];
37
+ const second = input[1];
38
+ const ascending = second > first;
39
+ if (ascending) {
40
+ if (x <= first)
41
+ return 0;
42
+ for (let i = 1; i < input.length; i++) {
43
+ const curr = input[i];
44
+ if (x <= curr)
45
+ return i - 1;
46
+ }
47
+ return input.length - 2;
48
+ }
49
+ else {
50
+ if (x >= first)
51
+ return 0;
52
+ for (let i = 1; i < input.length; i++) {
53
+ const curr = input[i];
54
+ if (x >= curr)
55
+ return i - 1;
56
+ }
57
+ return input.length - 2;
58
+ }
59
+ };
60
+ /**
61
+ * Creates a derived Svelte store that transforms values.
62
+ *
63
+ * Two supported forms (API parity with Motion's useTransform):
64
+ * - Mapping form: Map a numeric source across input/output ranges.
65
+ * Example: `useTransform(src, [0, 100], [0, 1], { clamp: true })`
66
+ * - Function form: Recompute from a function based on dependency stores.
67
+ * Example: `useTransform(() => compute(), [depA, depB])`
68
+ *
69
+ * @template T
70
+ * @param {Readable<number>|(() => T)} sourceOrCompute Numeric source store (mapping form), or compute function (function form).
71
+ * @param {number[]|Readable<unknown>[]} inputOrDeps Input stops (mapping) or dependency stores (function form).
72
+ * @param {T[]=} output Output stops (mapping form only). Must match input length.
73
+ * @param {TransformOptions=} options Mapping options (mapping form only).
74
+ * @returns {Readable<T>} A derived Svelte readable store.
75
+ * @see https://motion.dev/docs/react-use-transform?platform=react
76
+ */
77
+ export const useTransform = (sourceOrCompute, inputOrDeps, output, options = {}) => {
78
+ // Function form: (compute, deps)
79
+ if (typeof sourceOrCompute === 'function') {
80
+ const compute = sourceOrCompute;
81
+ const deps = inputOrDeps;
82
+ if (!deps || deps.length === 0)
83
+ return readable(compute());
84
+ return derived(deps, () => compute());
85
+ }
86
+ // Mapping form: (source, input, output, options)
87
+ const source = sourceOrCompute;
88
+ const input = inputOrDeps;
89
+ const out = (output ?? []);
90
+ const { clamp = true, ease, mixer } = options;
91
+ if (input.length !== out.length) {
92
+ throw new Error(`useTransform: input and output arrays must be the same length (input: ${input.length}, output: ${out.length})`);
93
+ }
94
+ const easings = Array.isArray(ease)
95
+ ? ease
96
+ : ease
97
+ ? new Array(Math.max(0, out.length - 1)).fill(ease)
98
+ : [];
99
+ return derived(source, (x) => {
100
+ if (input.length === 0)
101
+ return out[0];
102
+ if (input.length === 1)
103
+ return out[0];
104
+ const seg = findSegment(input, x);
105
+ const i0 = input[seg];
106
+ const i1 = input[seg + 1];
107
+ const o0 = out[seg];
108
+ const o1 = out[seg + 1];
109
+ // Runtime validation to avoid non-null assertions
110
+ if (i0 === undefined || i1 === undefined || o0 === undefined || o1 === undefined) {
111
+ console.warn('useTransform: Invalid segment bounds', {
112
+ seg,
113
+ inputLength: input.length,
114
+ outputLength: out.length
115
+ });
116
+ return out[0];
117
+ }
118
+ const localClamp = clamp ? clampBidirectional : (val) => val;
119
+ const progress = i0 === i1 ? 0 : (localClamp(x, i0, i1) - i0) / (i1 - i0);
120
+ const e = easings[seg];
121
+ const p = e ? e(progress) : progress;
122
+ const mix = mixer
123
+ ? mixer(o0, o1)
124
+ : typeof o0 === 'number' && typeof o1 === 'number'
125
+ ? linearMix(o0, o1)
126
+ : (_t) => (p < 0.5 ? o0 : o1);
127
+ return mix(p);
128
+ });
129
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanspeak/svelte-motion",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "A lightweight 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",