@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
129 lines (128 loc) • 4.77 kB
JavaScript
import { cancelFrame, frame, motionValue } from 'motion-dom';
import { SvelteMap } from 'svelte/reactivity';
import { augmentMotionValue } from './augmentMotionValue.svelte.js';
// `SvelteMap` (not plain Map) per `eslint/svelte/prefer-svelte-reactivity`
// inside `.svelte.ts` files. The contents aren't read in reactive scopes —
// this is a plain keyed cache — but the linter rule applies uniformly.
const sharedTimelines = new SvelteMap();
// Clear shared timelines on HMR dispose to avoid stale entries across hot
// reloads.
const hot = import.meta.hot;
if (hot) {
hot.dispose(() => {
for (const t of sharedTimelines.values()) {
t.cancel();
t.base.destroy();
}
sharedTimelines.clear();
});
}
/**
* Starts a keep-alive frame-loop callback that writes elapsed-milliseconds
* into a fresh `MotionValue<number>`. Returns the value and a cancel
* function that stops the loop. Caller owns the value's destroy lifecycle.
*
* Uses motion-dom's `frame.update(cb, true)` — `true` is the `keepAlive`
* flag, telling the frame loop to re-schedule the callback every frame
* automatically. Matches React framer-motion's `useAnimationFrame`.
*/
const startTimeBase = () => {
const base = motionValue(0);
let start = 0;
const tick = ({ timestamp }) => {
if (!start)
start = timestamp;
base.set(timestamp - start);
};
frame.update(tick, true);
return {
base,
cancel: () => cancelFrame(tick)
};
};
/**
* Returns an augmented `MotionValue<number>` that ticks once per render
* frame with the milliseconds elapsed since the value was created.
*
* Mirrors React framer-motion's `useTime` 1:1: a `MotionValue<number>`
* driven by motion-dom's `frame.update(tick, true)` keep-alive callback.
* The frame loop dedupes per-frame work across all motion-dom consumers,
* so multiple `useTime()` calls share the same render schedule.
*
* Two modes:
*
* - **Unique timeline** — `useTime()` starts its own keep-alive callback.
* The motion value and the callback are torn down when the surrounding
* `$effect` scope unmounts.
* - **Shared timeline** — `useTime(id)` callers passing the same `id` all
* observe a single shared frame-loop callback. Each call still returns
* an independent motion value (so destroying one consumer's value
* doesn't ripple to others) but the values stay in lockstep. The
* shared callback cancels the moment the last consumer unmounts; the
* next `useTime(id)` call restarts it.
*
* The result is augmented with a `$state`-backed `.current` getter and a
* `.subscribe` shim — it composes with `useTransform`, `useSpring`, and
* the rest of the Tier 2 surface.
*
* Lifecycle: must be called during component initialization. SSR-safe:
* returns a static `motionValue(0)` with no frame loop on the server.
*
* @param id Optional timeline identifier for sharing across components.
* @returns A `MotionValue<number>` with `.current` and `.subscribe`.
*
* @example
* ```svelte
* <script>
* import { useTime, useTransform } from '@humanspeak/svelte-motion'
*
* const time = useTime()
* const rotate = useTransform(time, [0, 4000], [0, 360], { clamp: false })
* </script>
*
* <div style="transform: rotate({rotate.current}deg)">↻</div>
* ```
*
* @see https://motion.dev/docs/react-use-time
*/
export const useTime = (id) => {
// SSR: return a static motion value with no frame loop. Matches
// useSpring / useScroll's SSR branch — no $effect is registered.
if (typeof window === 'undefined') {
return augmentMotionValue(motionValue(0));
}
// Unique timeline: own callback, own MV, own lifecycle.
if (!id) {
const { base, cancel } = startTimeBase();
$effect(() => () => {
cancel();
base.destroy();
});
return augmentMotionValue(base);
}
// Shared timeline: one frame callback per `id`, mirrored into per-
// consumer MVs.
let timeline = sharedTimelines.get(id);
if (!timeline) {
const { base, cancel } = startTimeBase();
timeline = { base, refcount: 0, cancel };
sharedTimelines.set(id, timeline);
}
timeline.refcount++;
const consumerMv = motionValue(timeline.base.get());
const unsubBase = timeline.base.on('change', (t) => consumerMv.set(t));
$effect(() => () => {
unsubBase();
consumerMv.destroy();
const t = sharedTimelines.get(id);
if (!t)
return;
t.refcount--;
if (t.refcount > 0)
return;
t.cancel();
t.base.destroy();
sharedTimelines.delete(id);
});
return augmentMotionValue(consumerMv);
};