@humanspeak/svelte-motion 0.4.9 → 0.5.0

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,128 @@
1
+ import { cancelFrame, frame, motionValue } from 'motion-dom';
2
+ import { SvelteMap } from 'svelte/reactivity';
3
+ import { augmentMotionValue } from './augmentMotionValue.svelte.js';
4
+ // `SvelteMap` (not plain Map) per `eslint/svelte/prefer-svelte-reactivity`
5
+ // inside `.svelte.ts` files. The contents aren't read in reactive scopes —
6
+ // this is a plain keyed cache — but the linter rule applies uniformly.
7
+ const sharedTimelines = new SvelteMap();
8
+ // Clear shared timelines on HMR dispose to avoid stale entries across hot
9
+ // reloads.
10
+ const hot = import.meta.hot;
11
+ if (hot) {
12
+ hot.dispose(() => {
13
+ for (const t of sharedTimelines.values()) {
14
+ t.cancel();
15
+ t.base.destroy();
16
+ }
17
+ sharedTimelines.clear();
18
+ });
19
+ }
20
+ /**
21
+ * Starts a keep-alive frame-loop callback that writes elapsed-milliseconds
22
+ * into a fresh `MotionValue<number>`. Returns the value and a cancel
23
+ * function that stops the loop. Caller owns the value's destroy lifecycle.
24
+ *
25
+ * Uses motion-dom's `frame.update(cb, true)` — `true` is the `keepAlive`
26
+ * flag, telling the frame loop to re-schedule the callback every frame
27
+ * automatically. Matches React framer-motion's `useAnimationFrame`.
28
+ */
29
+ const startTimeBase = () => {
30
+ const base = motionValue(0);
31
+ let start = 0;
32
+ const tick = ({ timestamp }) => {
33
+ if (!start)
34
+ start = timestamp;
35
+ base.set(timestamp - start);
36
+ };
37
+ frame.update(tick, true);
38
+ return {
39
+ base,
40
+ cancel: () => cancelFrame(tick)
41
+ };
42
+ };
43
+ /**
44
+ * Returns an augmented `MotionValue<number>` that ticks once per render
45
+ * frame with the milliseconds elapsed since the value was created.
46
+ *
47
+ * Mirrors React framer-motion's `useTime` 1:1: a `MotionValue<number>`
48
+ * driven by motion-dom's `frame.update(tick, true)` keep-alive callback.
49
+ * The frame loop dedupes per-frame work across all motion-dom consumers,
50
+ * so multiple `useTime()` calls share the same render schedule.
51
+ *
52
+ * Two modes:
53
+ *
54
+ * - **Unique timeline** — `useTime()` starts its own keep-alive callback.
55
+ * The motion value and the callback are torn down when the surrounding
56
+ * `$effect` scope unmounts.
57
+ * - **Shared timeline** — `useTime(id)` callers passing the same `id` all
58
+ * observe a single shared frame-loop callback. Each call still returns
59
+ * an independent motion value (so destroying one consumer's value
60
+ * doesn't ripple to others) but the values stay in lockstep. The
61
+ * shared callback cancels the moment the last consumer unmounts; the
62
+ * next `useTime(id)` call restarts it.
63
+ *
64
+ * The result is augmented with a `$state`-backed `.current` getter and a
65
+ * `.subscribe` shim — it composes with `useTransform`, `useSpring`, and
66
+ * the rest of the Tier 2 surface.
67
+ *
68
+ * Lifecycle: must be called during component initialization. SSR-safe:
69
+ * returns a static `motionValue(0)` with no frame loop on the server.
70
+ *
71
+ * @param id Optional timeline identifier for sharing across components.
72
+ * @returns A `MotionValue<number>` with `.current` and `.subscribe`.
73
+ *
74
+ * @example
75
+ * ```svelte
76
+ * <script>
77
+ * import { useTime, useTransform } from '@humanspeak/svelte-motion'
78
+ *
79
+ * const time = useTime()
80
+ * const rotate = useTransform(time, [0, 4000], [0, 360], { clamp: false })
81
+ * </script>
82
+ *
83
+ * <div style="transform: rotate({rotate.current}deg)">↻</div>
84
+ * ```
85
+ *
86
+ * @see https://motion.dev/docs/react-use-time
87
+ */
88
+ export const useTime = (id) => {
89
+ // SSR: return a static motion value with no frame loop. Matches
90
+ // useSpring / useScroll's SSR branch — no $effect is registered.
91
+ if (typeof window === 'undefined') {
92
+ return augmentMotionValue(motionValue(0));
93
+ }
94
+ // Unique timeline: own callback, own MV, own lifecycle.
95
+ if (!id) {
96
+ const { base, cancel } = startTimeBase();
97
+ $effect(() => () => {
98
+ cancel();
99
+ base.destroy();
100
+ });
101
+ return augmentMotionValue(base);
102
+ }
103
+ // Shared timeline: one frame callback per `id`, mirrored into per-
104
+ // consumer MVs.
105
+ let timeline = sharedTimelines.get(id);
106
+ if (!timeline) {
107
+ const { base, cancel } = startTimeBase();
108
+ timeline = { base, refcount: 0, cancel };
109
+ sharedTimelines.set(id, timeline);
110
+ }
111
+ timeline.refcount++;
112
+ const consumerMv = motionValue(timeline.base.get());
113
+ const unsubBase = timeline.base.on('change', (t) => consumerMv.set(t));
114
+ $effect(() => () => {
115
+ unsubBase();
116
+ consumerMv.destroy();
117
+ const t = sharedTimelines.get(id);
118
+ if (!t)
119
+ return;
120
+ t.refcount--;
121
+ if (t.refcount > 0)
122
+ return;
123
+ t.cancel();
124
+ t.base.destroy();
125
+ sharedTimelines.delete(id);
126
+ });
127
+ return augmentMotionValue(consumerMv);
128
+ };
@@ -0,0 +1,170 @@
1
+ import { type MotionValue, type TransformOptions } from 'motion-dom';
2
+ import { type Readable } from 'svelte/store';
3
+ import { type AugmentedMotionValue } from './augmentMotionValue.svelte.js';
4
+ export type TransformValues = Partial<{
5
+ x: number;
6
+ y: number;
7
+ scale: number;
8
+ scaleX: number;
9
+ scaleY: number;
10
+ rotate: number;
11
+ }>;
12
+ /**
13
+ * Build a CSS transform string from numeric values (no matrices).
14
+ *
15
+ * @param values Partial map of translate/scale/rotate values.
16
+ * @returns A space-separated CSS `transform` string, or `""` when all values are defaults.
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * buildTransform({ x: 10, y: 20, scale: 1.5 })
21
+ * // => "translate(10px, 20px) scale(1.5)"
22
+ *
23
+ * buildTransform({ x: 0, y: 0, rotate: 0 }) // all defaults
24
+ * // => ""
25
+ * ```
26
+ */
27
+ export declare const buildTransform: (values: TransformValues) => string;
28
+ /**
29
+ * Lightweight safety check for transform magnitudes and NaN values.
30
+ *
31
+ * @param values Transform values to validate.
32
+ * @param opts Optional configuration; `maxScale` caps allowable absolute scale (default 8).
33
+ * @returns `true` if all scale values are finite and within bounds.
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * isSafeTransform({ scale: 2 }) // true
38
+ * isSafeTransform({ scale: 100 }) // false (exceeds default maxScale=8)
39
+ * isSafeTransform({ scale: 100 }, { maxScale: 200 }) // true
40
+ * isSafeTransform({ scale: NaN }) // false
41
+ * ```
42
+ */
43
+ export declare const isSafeTransform: (values: TransformValues, opts?: {
44
+ maxScale?: number;
45
+ }) => boolean;
46
+ /**
47
+ * Extract the uniform scale factor from a CSS `matrix()` string.
48
+ *
49
+ * @param matrix A CSS `matrix(...)` value, `"none"`, `null`, or `undefined`.
50
+ * @returns The `a` component of the matrix (uniform scale), or `null` if unparseable.
51
+ *
52
+ * @example
53
+ * ```ts
54
+ * parseMatrixScale('matrix(1.5, 0, 0, 1.5, 0, 0)') // 1.5
55
+ * parseMatrixScale('none') // null
56
+ * parseMatrixScale(null) // null
57
+ * ```
58
+ */
59
+ export declare const parseMatrixScale: (matrix: string | null | undefined) => number | null;
60
+ export type { TransformOptions };
61
+ /**
62
+ * Any motion-value shape this library accepts as a transform input: the raw
63
+ * motion-dom `MotionValue<T>` and our augmented `AugmentedMotionValue<T>` are
64
+ * both runtime-identical (the augmentation is a Svelte 5 retype, not a
65
+ * subclass), but TypeScript's private-field nominal typing means the two
66
+ * aren't structurally assignable in public signatures. This alias accepts
67
+ * both so call sites compile cleanly.
68
+ */
69
+ export type AnyMotionValue<T> = MotionValue<T> | AugmentedMotionValue<T>;
70
+ /**
71
+ * A source for {@link useTransform}'s mapping form: any motion value or a
72
+ * Svelte readable store. Svelte readables are bridged into a `MotionValue`
73
+ * internally so motion-dom's `mapValue` / `transformValue` can track them.
74
+ */
75
+ export type TransformSource<T> = AnyMotionValue<T> | Readable<T>;
76
+ /**
77
+ * Single-input transformer signature: `(latest) => output`.
78
+ */
79
+ export type SingleTransformer<I, O> = (input: I) => O;
80
+ /**
81
+ * Multi-input transformer signature: `([latestA, latestB, ...]) => output`.
82
+ */
83
+ export type MultiTransformer<I, O> = (inputs: I[]) => O;
84
+ /**
85
+ * Output map for the multi-output mapping form: `{ key: [stop, …] }`. The
86
+ * keys are stable per call and each entry produces its own `MotionValue`.
87
+ */
88
+ export type TransformOutputMap<O> = {
89
+ [key: string]: O[];
90
+ };
91
+ /**
92
+ * Creates an augmented `MotionValue<O>` derived from one or more `MotionValue`s
93
+ * (or Svelte readables), composed via a range mapping or a compute function.
94
+ *
95
+ * Mirrors framer-motion's `useTransform` 1:1 with five forms:
96
+ *
97
+ * - **Mapping form** — `useTransform(source, input, output, options?)` maps a
98
+ * numeric source's value across `input → output` stops with clamp, easing,
99
+ * and a pluggable mixer for non-numeric outputs. Delegates to motion-dom's
100
+ * `mapValue` (which is itself `transformValue` over the curried mapper).
101
+ * - **Multi-output mapping form** — `useTransform(source, input, outputMap, options?)`
102
+ * produces an object of motion values, one per key in `outputMap`. Each
103
+ * value is the same mapping form applied to that key's output stops.
104
+ * - **Single-transformer form** — `useTransform(mv, (latest) => O)` recomputes
105
+ * on every change of `mv`. Auto-tracks via `transformValue`.
106
+ * - **Multi-transformer form** — `useTransform([mv1, mv2, …], ([latest, …]) => O)`
107
+ * recomputes on every change of any input. Auto-tracks via `transformValue`.
108
+ * - **Compute form** — `useTransform(() => compute)` recomputes whenever any
109
+ * `MotionValue` referenced inside `compute` (via `.get()` or `.current`)
110
+ * changes. Auto-tracks via `transformValue` — no explicit deps array; the
111
+ * `collectMotionValues` session inside `transformValue` discovers them.
112
+ *
113
+ * Returns an {@link AugmentedMotionValue<O>} — a real motion-dom `MotionValue`
114
+ * (composes with `useSpring`, `useVelocity`, `animate()`, etc.) plus a
115
+ * `$state`-backed `.current` getter and a Svelte readable `.subscribe` shim.
116
+ *
117
+ * Lifecycle: must be called during component initialization. Source
118
+ * subscriptions, the underlying motion value, and any Svelte-readable bridges
119
+ * are torn down when the surrounding `$effect` scope unmounts.
120
+ *
121
+ * @template O Output value type.
122
+ * @param sourceOrCompute A motion value / readable (mapping forms), a single
123
+ * `MotionValue` (single-transformer form), an array of `MotionValue`s
124
+ * (multi-transformer form), or a compute function (compute form).
125
+ * @param inputOrTransformer Input stops `number[]` (mapping forms) or a
126
+ * transformer function (`(latest) => O` / `(latest[]) => O`).
127
+ * @param outputOrOutputMap Output stops `O[]` (single-output mapping) or an
128
+ * output map `{ [key]: O[] }` (multi-output mapping).
129
+ * @param options Optional `TransformOptions` — `clamp`, `ease`, `mixer`.
130
+ * @returns An `AugmentedMotionValue<O>` for single-output forms, or an object
131
+ * of motion values keyed by `outputMap` for the multi-output form.
132
+ *
133
+ * @example
134
+ * ```svelte
135
+ * <script lang="ts">
136
+ * import { useMotionValue, useTransform } from '@humanspeak/svelte-motion'
137
+ *
138
+ * const x = useMotionValue(0)
139
+ *
140
+ * // Mapping form
141
+ * const opacity = useTransform(x, [0, 100], [0, 1])
142
+ *
143
+ * // Single-MV transformer
144
+ * const doubled = useTransform(x, (latest) => latest * 2)
145
+ *
146
+ * // Compute form — auto-tracks via collectMotionValues
147
+ * const y = useMotionValue(0)
148
+ * const sum = useTransform(() => x.get() + y.get())
149
+ *
150
+ * // Multi-output mapping
151
+ * const { rotate, scale } = useTransform(x, [0, 100], {
152
+ * rotate: [0, 360],
153
+ * scale: [1, 2]
154
+ * })
155
+ * </script>
156
+ *
157
+ * <div style="opacity: {opacity.current}; transform: rotate({rotate.current}deg) scale({scale.current})">
158
+ * {sum.current}
159
+ * </div>
160
+ * ```
161
+ *
162
+ * @see https://motion.dev/docs/react-use-transform
163
+ */
164
+ export declare function useTransform<O>(source: TransformSource<number>, input: number[], output: O[], options?: TransformOptions<O>): AugmentedMotionValue<O>;
165
+ export declare function useTransform<T extends TransformOutputMap<unknown>>(source: TransformSource<number>, input: number[], outputMap: T, options?: TransformOptions<T[keyof T][number]>): {
166
+ [K in keyof T]: AugmentedMotionValue<T[K][number]>;
167
+ };
168
+ export declare function useTransform<I, O>(source: AnyMotionValue<I>, transformer: SingleTransformer<I, O>): AugmentedMotionValue<O>;
169
+ export declare function useTransform<I, O>(sources: ReadonlyArray<AnyMotionValue<I>>, transformer: MultiTransformer<I, O>): AugmentedMotionValue<O>;
170
+ export declare function useTransform<O>(compute: () => O): AugmentedMotionValue<O>;
@@ -0,0 +1,189 @@
1
+ // Utilities for building and validating transform strings
2
+ import { isMotionValue, mapValue, transformValue } from 'motion-dom';
3
+ import {} from 'svelte/store';
4
+ import { augmentMotionValue, bridgeReadableToMotionValue } from './augmentMotionValue.svelte.js';
5
+ const DEFAULTS = {
6
+ x: 0,
7
+ y: 0,
8
+ scale: 1,
9
+ scaleX: 1,
10
+ scaleY: 1,
11
+ rotate: 0
12
+ };
13
+ /**
14
+ * Build a CSS transform string from numeric values (no matrices).
15
+ *
16
+ * @param values Partial map of translate/scale/rotate values.
17
+ * @returns A space-separated CSS `transform` string, or `""` when all values are defaults.
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * buildTransform({ x: 10, y: 20, scale: 1.5 })
22
+ * // => "translate(10px, 20px) scale(1.5)"
23
+ *
24
+ * buildTransform({ x: 0, y: 0, rotate: 0 }) // all defaults
25
+ * // => ""
26
+ * ```
27
+ */
28
+ export const buildTransform = (values) => {
29
+ const v = { ...DEFAULTS, ...values };
30
+ const useAxes = values.scaleX !== undefined || values.scaleY !== undefined;
31
+ const parts = [];
32
+ if (v.x !== 0 || v.y !== 0)
33
+ parts.push(`translate(${round(v.x)}px, ${round(v.y)}px)`);
34
+ if (v.rotate !== 0)
35
+ parts.push(`rotate(${round(v.rotate)}deg)`);
36
+ if (useAxes) {
37
+ parts.push(`scaleX(${round(v.scaleX)})`);
38
+ parts.push(`scaleY(${round(v.scaleY)})`);
39
+ }
40
+ else if (v.scale !== 1) {
41
+ parts.push(`scale(${round(v.scale)})`);
42
+ }
43
+ return parts.join(' ').trim();
44
+ };
45
+ /**
46
+ * Lightweight safety check for transform magnitudes and NaN values.
47
+ *
48
+ * @param values Transform values to validate.
49
+ * @param opts Optional configuration; `maxScale` caps allowable absolute scale (default 8).
50
+ * @returns `true` if all scale values are finite and within bounds.
51
+ *
52
+ * @example
53
+ * ```ts
54
+ * isSafeTransform({ scale: 2 }) // true
55
+ * isSafeTransform({ scale: 100 }) // false (exceeds default maxScale=8)
56
+ * isSafeTransform({ scale: 100 }, { maxScale: 200 }) // true
57
+ * isSafeTransform({ scale: NaN }) // false
58
+ * ```
59
+ */
60
+ export const isSafeTransform = (values, opts) => {
61
+ const maxScale = opts?.maxScale ?? 8;
62
+ const entries = [
63
+ ['scale', values.scale],
64
+ ['scaleX', values.scaleX],
65
+ ['scaleY', values.scaleY]
66
+ ];
67
+ for (const [, val] of entries) {
68
+ if (val === undefined)
69
+ continue;
70
+ if (!Number.isFinite(val))
71
+ return false;
72
+ if (Math.abs(val) > maxScale)
73
+ return false;
74
+ }
75
+ return true;
76
+ };
77
+ /**
78
+ * Extract the uniform scale factor from a CSS `matrix()` string.
79
+ *
80
+ * @param matrix A CSS `matrix(...)` value, `"none"`, `null`, or `undefined`.
81
+ * @returns The `a` component of the matrix (uniform scale), or `null` if unparseable.
82
+ *
83
+ * @example
84
+ * ```ts
85
+ * parseMatrixScale('matrix(1.5, 0, 0, 1.5, 0, 0)') // 1.5
86
+ * parseMatrixScale('none') // null
87
+ * parseMatrixScale(null) // null
88
+ * ```
89
+ */
90
+ export const parseMatrixScale = (matrix) => {
91
+ if (!matrix || matrix === 'none')
92
+ return null;
93
+ const m = matrix.match(/matrix\(([^)]+)\)/);
94
+ if (!m)
95
+ return null;
96
+ const [a] = m[1].split(',').map((s) => parseFloat(s.trim()));
97
+ return Number.isFinite(a) ? a : null;
98
+ };
99
+ /**
100
+ * Round a number to six decimal places to avoid excessive precision in CSS strings.
101
+ *
102
+ * @param n The number to round.
103
+ * @returns The rounded value.
104
+ */
105
+ const round = (n) => {
106
+ return Math.round(n * 1e6) / 1e6;
107
+ };
108
+ /**
109
+ * Normalizes a `TransformSource<T>` into a `MotionValue<T>`. Returns the
110
+ * source as-is for any motion-value input (no bridge), or a bridge
111
+ * `MotionValue` + cleanup for `Readable` inputs. The cast at the
112
+ * motion-value branch is safe — both `MotionValue` and `AugmentedMotionValue`
113
+ * are the same instance at runtime.
114
+ */
115
+ const toMotionValue = (source) => {
116
+ if (isMotionValue(source)) {
117
+ return { value: source, dispose: () => undefined };
118
+ }
119
+ return bridgeReadableToMotionValue(source);
120
+ };
121
+ export function useTransform(sourceOrCompute, inputOrTransformer, outputOrOutputMap, options) {
122
+ // Compute form: useTransform(() => compute).
123
+ if (typeof sourceOrCompute === 'function') {
124
+ const compute = sourceOrCompute;
125
+ const value = transformValue(compute);
126
+ $effect(() => () => value.destroy());
127
+ return augmentMotionValue(value);
128
+ }
129
+ // Multi-input list form: useTransform([mv, mv, …], ([a, b, …]) => O).
130
+ if (Array.isArray(sourceOrCompute) && typeof inputOrTransformer === 'function') {
131
+ const sources = sourceOrCompute;
132
+ const transformer = inputOrTransformer;
133
+ const value = transformValue(() => {
134
+ const latest = [];
135
+ for (let i = 0; i < sources.length; i++) {
136
+ latest.push(sources[i].get());
137
+ }
138
+ return transformer(latest);
139
+ });
140
+ $effect(() => () => value.destroy());
141
+ return augmentMotionValue(value);
142
+ }
143
+ // Single-MV transformer form: useTransform(mv, (latest) => O).
144
+ if (typeof inputOrTransformer === 'function') {
145
+ const source = sourceOrCompute;
146
+ const transformer = inputOrTransformer;
147
+ const value = transformValue(() => transformer(source.get()));
148
+ $effect(() => () => value.destroy());
149
+ return augmentMotionValue(value);
150
+ }
151
+ // Mapping forms (single output array or output map).
152
+ const source = sourceOrCompute;
153
+ const input = inputOrTransformer ?? [];
154
+ const { value: numericSource, dispose: disposeBridge } = toMotionValue(source);
155
+ // Multi-output mapping form: useTransform(source, [range], { key: [out], … }, options).
156
+ // The `outputOrOutputMap !== null` check is load-bearing — `typeof null`
157
+ // is `'object'`, so a `null` argument would otherwise enter this branch
158
+ // and crash at `Object.keys(null)`.
159
+ if (outputOrOutputMap !== undefined &&
160
+ outputOrOutputMap !== null &&
161
+ !Array.isArray(outputOrOutputMap) &&
162
+ typeof outputOrOutputMap === 'object') {
163
+ const outputMap = outputOrOutputMap;
164
+ const keys = Object.keys(outputMap);
165
+ const result = {};
166
+ const inners = [];
167
+ for (const key of keys) {
168
+ const inner = mapValue(numericSource, input, outputMap[key], options);
169
+ inners.push(inner);
170
+ result[key] = augmentMotionValue(inner);
171
+ }
172
+ // One cleanup effect for all per-key MVs plus the shared bridge —
173
+ // saves N effect nodes vs. registering one per key.
174
+ $effect(() => () => {
175
+ for (const inner of inners)
176
+ inner.destroy();
177
+ disposeBridge();
178
+ });
179
+ return result;
180
+ }
181
+ // Single-output mapping form: useTransform(source, [range], [out], options).
182
+ const output = outputOrOutputMap ?? [];
183
+ const value = mapValue(numericSource, input, output, options);
184
+ $effect(() => () => {
185
+ value.destroy();
186
+ disposeBridge();
187
+ });
188
+ return augmentMotionValue(value);
189
+ }
@@ -0,0 +1,61 @@
1
+ import { type Readable } from 'svelte/store';
2
+ import { type AugmentedMotionValue } from './augmentMotionValue.svelte.js';
3
+ /**
4
+ * Source for {@link useVelocity}: a motion-dom `MotionValue` or a Svelte
5
+ * readable. Svelte readables are bridged into a `MotionValue` so motion-dom's
6
+ * native velocity tracking can drive the result.
7
+ */
8
+ export type VelocitySource = AugmentedMotionValue<number | string> | Readable<number | string>;
9
+ /**
10
+ * Creates an augmented `MotionValue<number>` whose value tracks the velocity
11
+ * of `source` in units/second.
12
+ *
13
+ * Mirrors React framer-motion 1:1: on every `source` change, schedules an
14
+ * `updateVelocity` callback in motion-dom's frame loop via
15
+ * `frame.update(updateVelocity, false, true)`. `updateVelocity` reads
16
+ * `source.getVelocity()` (motion-dom tracks per-frame deltas + timestamps
17
+ * for free) and writes it to the result. If velocity is still non-zero,
18
+ * `updateVelocity` re-schedules itself for the next frame — so the loop
19
+ * only runs while there's actual motion and snaps to `0` the moment things
20
+ * settle. Idle CPU is zero.
21
+ *
22
+ * Returned value is a real motion-dom `MotionValue` (composes with
23
+ * `useTransform`, `useSpring`, `useMotionTemplate`, etc.) plus a
24
+ * `$state`-backed `.current` getter and a `.subscribe` shim.
25
+ *
26
+ * Lifecycle: must be called during component initialization. The change
27
+ * subscription, the frame-loop callback, and any Svelte-readable bridge are
28
+ * all torn down when the surrounding `$effect` unmounts.
29
+ *
30
+ * SSR-safe: returns a static augmented motion value with no subscriptions
31
+ * and no frame loop on the server.
32
+ *
33
+ * @param source A motion value or readable store of numeric or unit-string values.
34
+ * @returns A `MotionValue<number>` with `.current` and `.subscribe`.
35
+ *
36
+ * @example
37
+ * ```svelte
38
+ * <script lang="ts">
39
+ * import {
40
+ * useMotionValue,
41
+ * useTransform,
42
+ * useVelocity
43
+ * } from '@humanspeak/svelte-motion'
44
+ *
45
+ * const x = useMotionValue(0)
46
+ * const xVelocity = useVelocity(x)
47
+ * // Map velocity to a momentum-driven skew. Skew goes positive when x is
48
+ * // accelerating right, negative when accelerating left, and snaps to 0
49
+ * // when motion settles.
50
+ * const skew = useTransform(xVelocity, [-1000, 0, 1000], [-15, 0, 15])
51
+ * </script>
52
+ *
53
+ * <div
54
+ * style="transform: translateX({x.current}px) skewX({skew.current}deg)"
55
+ * onpointermove={(e) => x.set(e.clientX)}
56
+ * />
57
+ * ```
58
+ *
59
+ * @see https://motion.dev/docs/react-use-velocity
60
+ */
61
+ export declare const useVelocity: (source: VelocitySource) => AugmentedMotionValue<number>;
@@ -0,0 +1,132 @@
1
+ import { cancelFrame, frame, isMotionValue, motionValue } from 'motion-dom';
2
+ import {} from 'svelte/store';
3
+ import { augmentMotionValue, bridgeReadableToMotionValue } from './augmentMotionValue.svelte.js';
4
+ /**
5
+ * Parses a numeric value from a number or unit string (e.g. `"100px"` → `100`).
6
+ */
7
+ const parseNumeric = (v) => {
8
+ if (typeof v === 'number')
9
+ return v;
10
+ const parsed = Number.parseFloat(String(v));
11
+ return Number.isFinite(parsed) ? parsed : 0;
12
+ };
13
+ /**
14
+ * Creates an augmented `MotionValue<number>` whose value tracks the velocity
15
+ * of `source` in units/second.
16
+ *
17
+ * Mirrors React framer-motion 1:1: on every `source` change, schedules an
18
+ * `updateVelocity` callback in motion-dom's frame loop via
19
+ * `frame.update(updateVelocity, false, true)`. `updateVelocity` reads
20
+ * `source.getVelocity()` (motion-dom tracks per-frame deltas + timestamps
21
+ * for free) and writes it to the result. If velocity is still non-zero,
22
+ * `updateVelocity` re-schedules itself for the next frame — so the loop
23
+ * only runs while there's actual motion and snaps to `0` the moment things
24
+ * settle. Idle CPU is zero.
25
+ *
26
+ * Returned value is a real motion-dom `MotionValue` (composes with
27
+ * `useTransform`, `useSpring`, `useMotionTemplate`, etc.) plus a
28
+ * `$state`-backed `.current` getter and a `.subscribe` shim.
29
+ *
30
+ * Lifecycle: must be called during component initialization. The change
31
+ * subscription, the frame-loop callback, and any Svelte-readable bridge are
32
+ * all torn down when the surrounding `$effect` unmounts.
33
+ *
34
+ * SSR-safe: returns a static augmented motion value with no subscriptions
35
+ * and no frame loop on the server.
36
+ *
37
+ * @param source A motion value or readable store of numeric or unit-string values.
38
+ * @returns A `MotionValue<number>` with `.current` and `.subscribe`.
39
+ *
40
+ * @example
41
+ * ```svelte
42
+ * <script lang="ts">
43
+ * import {
44
+ * useMotionValue,
45
+ * useTransform,
46
+ * useVelocity
47
+ * } from '@humanspeak/svelte-motion'
48
+ *
49
+ * const x = useMotionValue(0)
50
+ * const xVelocity = useVelocity(x)
51
+ * // Map velocity to a momentum-driven skew. Skew goes positive when x is
52
+ * // accelerating right, negative when accelerating left, and snaps to 0
53
+ * // when motion settles.
54
+ * const skew = useTransform(xVelocity, [-1000, 0, 1000], [-15, 0, 15])
55
+ * </script>
56
+ *
57
+ * <div
58
+ * style="transform: translateX({x.current}px) skewX({skew.current}deg)"
59
+ * onpointermove={(e) => x.set(e.clientX)}
60
+ * />
61
+ * ```
62
+ *
63
+ * @see https://motion.dev/docs/react-use-velocity
64
+ */
65
+ export const useVelocity = (source) => {
66
+ // Bridge non-numeric sources into a MotionValue<number> so motion-dom's
67
+ // getVelocity() tracks deltas correctly. Two paths feed this:
68
+ // 1. Svelte readables — always bridged (motion-dom doesn't know how to
69
+ // read them).
70
+ // 2. MotionValue<string> — bridged too, because motion-dom samples
71
+ // `canTrackVelocity` ONCE from the initial value via
72
+ // `!isNaN(parseFloat(value))`. A string MV that starts non-numeric
73
+ // (e.g. `""`) gets stuck at velocity = 0 forever, even if it later
74
+ // becomes a unit string like `"100px"`. The bridge runs every emit
75
+ // through `parseNumeric` so the tracker MV is always numeric.
76
+ let tracker;
77
+ let disposeBridge;
78
+ if (isMotionValue(source)) {
79
+ const initial = source.get();
80
+ if (typeof initial === 'number') {
81
+ tracker = source;
82
+ }
83
+ else if (typeof window !== 'undefined') {
84
+ const bridge = motionValue(parseNumeric(initial));
85
+ const unsub = source.on('change', (v) => {
86
+ bridge.set(parseNumeric(v));
87
+ });
88
+ tracker = bridge;
89
+ disposeBridge = () => {
90
+ unsub();
91
+ bridge.destroy();
92
+ };
93
+ }
94
+ else {
95
+ // SSR: parse the initial value but don't subscribe — the change
96
+ // listener would leak past the early SSR return below (no
97
+ // $effect runs to call dispose).
98
+ tracker = motionValue(parseNumeric(initial));
99
+ }
100
+ }
101
+ else if (typeof window !== 'undefined') {
102
+ const bridge = bridgeReadableToMotionValue(source, parseNumeric);
103
+ tracker = bridge.value;
104
+ disposeBridge = bridge.dispose;
105
+ }
106
+ else {
107
+ tracker = motionValue(0);
108
+ }
109
+ const result = motionValue(tracker.getVelocity());
110
+ // SSR: skip the frame-loop wiring entirely and return a static MV.
111
+ if (typeof window === 'undefined') {
112
+ return augmentMotionValue(result);
113
+ }
114
+ const updateVelocity = () => {
115
+ const latest = tracker.getVelocity();
116
+ result.set(latest);
117
+ if (latest)
118
+ frame.update(updateVelocity);
119
+ };
120
+ const unsubChange = tracker.on('change', () => {
121
+ // keepAlive: false, immediate: true — run at end of current frame if
122
+ // we're already in one. Matches React framer-motion's useVelocity.
123
+ frame.update(updateVelocity, false, true);
124
+ });
125
+ $effect(() => () => {
126
+ unsubChange();
127
+ cancelFrame(updateVelocity);
128
+ disposeBridge?.();
129
+ result.destroy();
130
+ });
131
+ return augmentMotionValue(result);
132
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanspeak/svelte-motion",
3
- "version": "0.4.9",
3
+ "version": "0.5.0",
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",