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

260 lines (259 loc) 8.98 kB
import { scroll } from 'motion'; import { cancelMicrotask, microtask, motionValue, supportsScrollTimeline, supportsViewTimeline } from 'motion-dom'; import { augmentMotionValue } from './augmentMotionValue.svelte.js'; import { isRefPending, resolveElement } from './dom.js'; /** * Named view-timeline presets — mirror framer-motion's ScrollOffset table. * Each entry is a pair of `[target, container]` intersection points that * defines a phase of the scroll relationship. */ const VIEW_TIMELINE_PRESETS = [ [ [ [0, 1], [1, 1] ], 'entry' ], [ [ [0, 0], [1, 0] ], 'exit' ], [ [ [1, 0], [0, 1] ], 'cover' ], [ [ [0, 0], [1, 1] ], 'contain' ] ]; const PROGRESS_BY_STRING = { start: 0, end: 1 }; /** * Parse `"start end"` / `"end start"` / etc. into an explicit * `[target, container]` intersection pair. */ const parseStringOffset = (s) => { const parts = s.trim().split(/\s+/); if (parts.length !== 2) return undefined; const a = PROGRESS_BY_STRING[parts[0]]; const b = PROGRESS_BY_STRING[parts[1]]; if (a === undefined || b === undefined) return undefined; return [a, b]; }; /** * Normalize a {@link ScrollOffset} into an array of explicit intersection * pairs, or `undefined` if the shape doesn't match the simple two-element * preset form. */ const normaliseOffset = (offset) => { if (offset.length !== 2) return undefined; const result = []; for (const item of offset) { if (Array.isArray(item)) { result.push(item); } else if (typeof item === 'string') { const parsed = parseStringOffset(item); if (!parsed) return undefined; result.push(parsed); } else { return undefined; } } return result; }; const matchesPreset = (offset, preset) => { const normalised = normaliseOffset(offset); if (!normalised) return false; for (let i = 0; i < 2; i++) { const o = normalised[i]; const p = preset[i]; if (o[0] !== p[0] || o[1] !== p[1]) return false; } return true; }; /** * Map a {@link ScrollOffset} to its corresponding CSS view-timeline range, if * any. Returning `undefined` signals the caller to fall back to JS-driven * scroll tracking. Mirrors framer-motion's `offsetToViewTimelineRange`. */ const offsetToViewTimelineRange = (offset) => { if (!offset) return { rangeStart: 'contain 0%', rangeEnd: 'contain 100%' }; for (const [preset, name] of VIEW_TIMELINE_PRESETS) { if (matchesPreset(offset, preset)) { return { rangeStart: `${name} 0%`, rangeEnd: `${name} 100%` }; } } return undefined; }; /** * Whether this scroll configuration can be driven by a native CSS * scroll-timeline / view-timeline. When `true`, the resulting motion value * runs on the compositor thread without per-frame JS callbacks. */ const canAccelerateScroll = (target, offset) => { if (typeof window === 'undefined') return false; return target ? supportsViewTimeline() && !!offsetToViewTimelineRange(offset) : supportsScrollTimeline(); }; /** * Build the AccelerateConfig for a progress motion value driven by a native * scroll-timeline / view-timeline animation. Mirrors framer-motion's * `makeAccelerateConfig` 1:1: the `factory` defers `scroll()` until refs * hydrate via `microtask.read`, then attaches the animation; the `times` / * `keyframes` / `ease` / `duration` describe the 0→1 linear mapping. */ const makeAccelerateConfig = (axis, options) => ({ factory: (animation) => { let cleanup; const start = () => { if (isRefPending(options.container) || isRefPending(options.target)) { microtask.read(start); return; } cleanup = scroll(animation, { offset: options.offset, axis, container: resolveElement(options.container), target: resolveElement(options.target) }); }; microtask.read(start); return () => { cancelMicrotask(start); cleanup?.(); }; }, times: [0, 1], keyframes: [0, 1], ease: (v) => v, duration: 1 }); /** * Creates scroll-linked motion values for building scroll-driven animations * such as progress indicators and parallax effects. * * Returns four `MotionValue<number>`s: `scrollX` / `scrollY` (current * position in px) and `scrollXProgress` / `scrollYProgress` (0–1 * normalised). Each is a real motion-dom `MotionValue` augmented with a * `$state`-backed `.current` getter and a `.subscribe` shim, so they * compose with `useTransform`, `useSpring`, and the rest of the Tier 2 * surface, and they read reactively in Svelte 5 templates. * * **GPU-accelerated when supported.** On browsers that implement CSS * scroll-timeline (no `target`) or view-timeline (with a `target` and a * preset `offset`), the *Progress motion values run on the compositor * thread via WAAPI — no per-frame JS callback. The non-progress * `scrollX` / `scrollY` motion values always use the JS-driven `scroll()` * primitive since the absolute pixel offset isn't directly available from * native timelines. * * `container` and `target` accept either an `HTMLElement` directly or a * getter `() => HTMLElement | undefined`. The getter form is the right * choice with `bind:this`. Element resolution is deferred to a microtask * (matches React framer-motion 1:1, faster than rAF polling), so a getter * that hasn't hydrated yet is retried as soon as Svelte's mount tick * settles it. * * Lifecycle: the underlying `scroll()` observer and any accelerate factory * attach at mount via `$effect` and detach at unmount, regardless of how * many consumers are reading the values. The four motion values are torn * down at the same time. * * SSR-safe: returns four static `motionValue(0)`s with no scroll observer * on the server. * * @param options Optional scroll tracking configuration. * @returns Four `MotionValue<number>`s — `scrollX`, `scrollY`, `scrollXProgress`, `scrollYProgress`. * * @example * ```svelte * <script> * import { useScroll, useSpring } from '@humanspeak/svelte-motion' * * const { scrollYProgress } = useScroll() * const scaleX = useSpring(scrollYProgress) * </script> * * <div style="transform: scaleX({scaleX.current}); transform-origin: left;" /> * ``` * * @see https://motion.dev/docs/react-use-scroll */ export const useScroll = (options = {}) => { const scrollX = motionValue(0); const scrollY = motionValue(0); const scrollXProgress = motionValue(0); const scrollYProgress = motionValue(0); // SSR: return static motion values with no observer and no $effect. if (typeof window === 'undefined') { return { scrollX: augmentMotionValue(scrollX), scrollY: augmentMotionValue(scrollY), scrollXProgress: augmentMotionValue(scrollXProgress), scrollYProgress: augmentMotionValue(scrollYProgress) }; } // The *Progress MVs accelerate to the compositor thread when the browser // supports CSS scroll/view-timelines; the non-progress scrollX/scrollY // MVs always need the JS callback for absolute pixel offsets. if (canAccelerateScroll(options.target, options.offset)) { scrollXProgress.accelerate = makeAccelerateConfig('x', options); scrollYProgress.accelerate = makeAccelerateConfig('y', options); } let cleanup; const start = () => { if (isRefPending(options.container) || isRefPending(options.target)) { microtask.read(start); return; } cleanup = scroll((_progress, info) => { scrollX.set(info.x.current); scrollY.set(info.y.current); scrollXProgress.set(info.x.progress); scrollYProgress.set(info.y.progress); }, { container: resolveElement(options.container), target: resolveElement(options.target), offset: options.offset, axis: options.axis }); }; $effect(() => { microtask.read(start); return () => { cancelMicrotask(start); cleanup?.(); scrollX.destroy(); scrollY.destroy(); scrollXProgress.destroy(); scrollYProgress.destroy(); }; }); return { scrollX: augmentMotionValue(scrollX), scrollY: augmentMotionValue(scrollY), scrollXProgress: augmentMotionValue(scrollXProgress), scrollYProgress: augmentMotionValue(scrollYProgress) }; };