@humanspeak/svelte-motion
Version:
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
133 lines (132 loc) • 5.21 kB
JavaScript
import { cancelFrame, frame, isMotionValue, motionValue } from 'motion-dom';
import {} from 'svelte/store';
import { augmentMotionValue, bridgeReadableToMotionValue } from './augmentMotionValue.svelte.js';
/**
* Parses a numeric value from a number or unit string (e.g. `"100px"` → `100`).
*/
const parseNumeric = (v) => {
if (typeof v === 'number')
return v;
const parsed = Number.parseFloat(String(v));
return Number.isFinite(parsed) ? parsed : 0;
};
/**
* Creates an augmented `MotionValue<number>` whose value tracks the velocity
* of `source` in units/second.
*
* Mirrors React framer-motion 1:1: on every `source` change, schedules an
* `updateVelocity` callback in motion-dom's frame loop via
* `frame.update(updateVelocity, false, true)`. `updateVelocity` reads
* `source.getVelocity()` (motion-dom tracks per-frame deltas + timestamps
* for free) and writes it to the result. If velocity is still non-zero,
* `updateVelocity` re-schedules itself for the next frame — so the loop
* only runs while there's actual motion and snaps to `0` the moment things
* settle. Idle CPU is zero.
*
* Returned value is a real motion-dom `MotionValue` (composes with
* `useTransform`, `useSpring`, `useMotionTemplate`, etc.) plus a
* `$state`-backed `.current` getter and a `.subscribe` shim.
*
* Lifecycle: must be called during component initialization. The change
* subscription, the frame-loop callback, and any Svelte-readable bridge are
* all torn down when the surrounding `$effect` unmounts.
*
* SSR-safe: returns a static augmented motion value with no subscriptions
* and no frame loop on the server.
*
* @param source A motion value or readable store of numeric or unit-string values.
* @returns A `MotionValue<number>` with `.current` and `.subscribe`.
*
* @example
* ```svelte
* <script lang="ts">
* import {
* useMotionValue,
* useTransform,
* useVelocity
* } from '@humanspeak/svelte-motion'
*
* const x = useMotionValue(0)
* const xVelocity = useVelocity(x)
* // Map velocity to a momentum-driven skew. Skew goes positive when x is
* // accelerating right, negative when accelerating left, and snaps to 0
* // when motion settles.
* const skew = useTransform(xVelocity, [-1000, 0, 1000], [-15, 0, 15])
* </script>
*
* <div
* style="transform: translateX({x.current}px) skewX({skew.current}deg)"
* onpointermove={(e) => x.set(e.clientX)}
* />
* ```
*
* @see https://motion.dev/docs/react-use-velocity
*/
export const useVelocity = (source) => {
// Bridge non-numeric sources into a MotionValue<number> so motion-dom's
// getVelocity() tracks deltas correctly. Two paths feed this:
// 1. Svelte readables — always bridged (motion-dom doesn't know how to
// read them).
// 2. MotionValue<string> — bridged too, because motion-dom samples
// `canTrackVelocity` ONCE from the initial value via
// `!isNaN(parseFloat(value))`. A string MV that starts non-numeric
// (e.g. `""`) gets stuck at velocity = 0 forever, even if it later
// becomes a unit string like `"100px"`. The bridge runs every emit
// through `parseNumeric` so the tracker MV is always numeric.
let tracker;
let disposeBridge;
if (isMotionValue(source)) {
const initial = source.get();
if (typeof initial === 'number') {
tracker = source;
}
else if (typeof window !== 'undefined') {
const bridge = motionValue(parseNumeric(initial));
const unsub = source.on('change', (v) => {
bridge.set(parseNumeric(v));
});
tracker = bridge;
disposeBridge = () => {
unsub();
bridge.destroy();
};
}
else {
// SSR: parse the initial value but don't subscribe — the change
// listener would leak past the early SSR return below (no
// $effect runs to call dispose).
tracker = motionValue(parseNumeric(initial));
}
}
else if (typeof window !== 'undefined') {
const bridge = bridgeReadableToMotionValue(source, parseNumeric);
tracker = bridge.value;
disposeBridge = bridge.dispose;
}
else {
tracker = motionValue(0);
}
const result = motionValue(tracker.getVelocity());
// SSR: skip the frame-loop wiring entirely and return a static MV.
if (typeof window === 'undefined') {
return augmentMotionValue(result);
}
const updateVelocity = () => {
const latest = tracker.getVelocity();
result.set(latest);
if (latest)
frame.update(updateVelocity);
};
const unsubChange = tracker.on('change', () => {
// keepAlive: false, immediate: true — run at end of current frame if
// we're already in one. Matches React framer-motion's useVelocity.
frame.update(updateVelocity, false, true);
});
$effect(() => () => {
unsubChange();
cancelFrame(updateVelocity);
disposeBridge?.();
result.destroy();
});
return augmentMotionValue(result);
};