@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
JavaScript
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)
};
};