UNPKG

@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

194 lines (193 loc) 7.36 kB
import { isMotionValue, motionValue } from 'motion-dom'; import { get } from 'svelte/store'; /** * Detects a Svelte readable store, excluding motion-dom `MotionValue` * instances (which also expose `subscribe`-shaped APIs in some versions). * Used by hook factories that accept either a `MotionValue` or a readable * store as a source. * * @template T The value type the readable emits. * @param value Any value to test. * @returns Whether the value is a Svelte readable (and not a `MotionValue`). * * @example * ```ts * import { writable } from 'svelte/store' * import { motionValue } from 'motion-dom' * * isSvelteReadable(writable(0)) // true * isSvelteReadable(motionValue(0)) // false (a MotionValue, not a readable) * isSvelteReadable({ subscribe: 'nope' }) // false (subscribe isn't callable) * ``` */ export const isSvelteReadable = (value) => { return (!!value && typeof value === 'object' && typeof value.subscribe === 'function' && !isMotionValue(value)); }; /** * Synchronously samples a source: returns `T` directly, calls `.get()` on * a `MotionValue`, or `svelte/store`'s `get()` on a readable. Used by hook * factories to seed an initial value before any subscription is established. * * @template T The value type. * @param source A plain value, a `MotionValue`, or a Svelte readable. * @returns The current value of the source. * * @example * ```ts * sampleSource(42) // 42 * sampleSource(motionValue(7)) // 7 * sampleSource(writable(11)) // 11 * ``` */ export const sampleSource = (source) => { if (isMotionValue(source)) return source.get(); if (isSvelteReadable(source)) return get(source); return source; }; /** * Layer Svelte 5 affordances onto a motion-dom `MotionValue`: * * 1. A `$state`-tracked `.current` accessor. motion-dom writes to its own * `current` field on every frame via an internal setter; we redirect that * setter through `$state` so templates and `$derived` / `$effect` re-run * automatically. Same-value writes are skipped (motion-dom can call the * setter at rest, and `$state` would itself dedupe, but the explicit * check avoids the extra accessor work). * * 2. A `.subscribe(run)` shim implementing the Svelte readable store * contract: synchronous initial emit, then re-emit on every change. * Forwarded to motion-dom's `.on('change', …)` event bus. * * 3. `.destroy()` is wrapped so a caller-supplied `dispose` runs once before * motion-dom's own teardown (and only once, guarded against re-entrant * or duplicate destroy calls). * * The returned reference is the same `MotionValue` passed in, only retyped — * so identity checks (`isMotionValue`, `===`) still work and motion-dom's * own machinery (animation, follow, composition) is untouched. * * **Call once per MotionValue.** This function mutates the value (rewrites * `current`, `subscribe`, `destroy`). Calling it twice would re-define the * accessors and the first call's `dispose` would be discarded. * * @template T The value type — typically `number` or `string`. * @param value The motion-dom `MotionValue` to augment. * @param dispose Optional cleanup that runs once when `.destroy()` is first called (before motion-dom's internal teardown). Defaults to a no-op. * @returns The same `MotionValue` typed as {@link AugmentedMotionValue}. * * @example * ```svelte * <script lang="ts"> * import { motionValue } from 'motion-dom' * import { augmentMotionValue } from './augmentMotionValue.svelte.js' * * const mv = motionValue(0) * const aug = augmentMotionValue(mv, () => console.log('disposed')) * * $effect(() => () => aug.destroy()) * </script> * * <div style="transform: translateX({aug.current}px)">{aug.current}</div> * ``` */ export const augmentMotionValue = (value, dispose = () => undefined) => { // motion-dom's `.get()` returns `NonNullable<V>`, which would otherwise // narrow `$state` to that and reject nullable-T setter writes. Cast to T // so the state slot matches the public augmented signature; motion-dom's // own contract guarantees it never sets a null/undefined frame value. let current = $state(value.get()); Object.defineProperty(value, 'current', { get: () => current, set: (v) => { if (v !== current) current = v; }, enumerable: true, configurable: true }); const originalDestroy = value.destroy.bind(value); let destroyed = false; value.destroy = () => { if (destroyed) return; destroyed = true; dispose(); originalDestroy(); }; const subscribe = (run) => { run(value.get()); return value.on('change', run); }; Object.defineProperty(value, 'subscribe', { value: subscribe, writable: false, enumerable: false, configurable: true }); return value; }; /** * Bridges a Svelte `Readable<T>` into a motion-dom `MotionValue<T>` that * mirrors the readable's emissions, so motion-dom primitives (`mapValue`, * `transformValue`, `attachFollow`, `getVelocity`, etc.) that only accept * `MotionValue` can track readable-shaped sources. * * The bridge: * 1. Seeds via `get(source)` so the initial value is correct synchronously. * 2. Subscribes to the readable, skipping the *synchronous initial emit* * (Svelte readables always fire one on subscribe, but the seed already * has it — without the skip the bridge would double-write on attach). * 3. Optionally coerces each emit through `coerce` — useful for unit-string * sources (e.g. `"100px"` → `100`). * * Returns the bridge value and a `dispose` that tears down the subscription * and destroys the bridge MV. Callers register `dispose` with their lifecycle * ($effect cleanup or the augmented `destroy`'s `dispose` slot). * * @template TIn The readable's emit type (often `number | string`). * @template TOut The bridge MotionValue's value type (often `number`). * @param source A Svelte readable store. * @param coerce Optional transform applied to each emit (and the initial seed). Identity by default. * @returns A `MotionValue<TOut>` mirroring the readable + a dispose function. * * @example * ```ts * import { writable } from 'svelte/store' * * // Identity bridge — readable<number> → motionValue<number> * const w = writable(0) * const { value: mv, dispose } = bridgeReadableToMotionValue(w) * w.set(50); mv.get() === 50 * * // Coerce bridge — readable<string> → motionValue<number> * const w2 = writable('100px') * const bridge = bridgeReadableToMotionValue<string, number>(w2, parseFloat) * bridge.value.get() === 100 * * // Always pair with dispose() in your $effect cleanup. * $effect(() => () => dispose()) * ``` */ export const bridgeReadableToMotionValue = (source, coerce = (v) => v) => { const bridge = motionValue(coerce(get(source))); let seenInitial = false; const unsub = source.subscribe((v) => { if (!seenInitial) { seenInitial = true; return; } bridge.set(coerce(v)); }); return { value: bridge, dispose: () => { unsub(); bridge.destroy(); } }; };