@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
176 lines (175 loc) • 7.19 kB
JavaScript
/**
* Inertia → spring handoff utilities (axis-only, pure, testable).
*
* Implements exponential velocity decay until crossing a bound, then hands off
* to a damped spring targeting the nearest boundary. If out-of-bounds at t=0,
* spring starts immediately.
*
* This module is SSR-safe and holds no references to the DOM or timers.
*/
/**
* Compute the asymptotic displacement reachable by inertia with exponential
* velocity decay: x(t) = x0 + v0 * tau * (1 - e^{-t/tau}).
* The maximum reachable delta as t→∞ is v0 * tau.
*/
const maxInertiaReach = (v0, tau) => v0 * tau;
/**
* Closed form inertia position and velocity at elapsed time.
* - Position: x(t) = x0 + v0 * tau * (1 - e^{-t/tau})
* - Velocity: v(t) = v0 * e^{-t/tau}
*/
const inertiaAt = (x0, v0, tauMs, tMs) => {
const tauMsSafe = Math.max(1, tauMs);
const k = Math.exp(-tMs / tauMsSafe);
const tauSeconds = tauMsSafe / 1000;
const x = x0 + v0 * tauSeconds * (1 - k);
const v = v0 * k;
return { x, v };
};
/**
* Solve for first crossing time to a boundary if reachable.
* For boundary B in direction of v0, solve B = x0 + v0 * tau * (1 - e^{-t/tau}).
* t = -tau * ln(1 - (B - x0) / (v0 * tau))
*/
const solveCrossTimeMs = (x0, v0, tauMs, boundary) => {
if (v0 === 0)
return undefined;
const tau = Math.max(1, tauMs);
const reach = maxInertiaReach(v0, tau / 1000); // v0 (px/s) * tau(s) = px
const delta = boundary - x0;
// Must be in forward direction and within asymptotic reach
if (Math.sign(delta) !== Math.sign(v0) || Math.abs(delta) > Math.abs(reach))
return undefined;
const r = 1 - delta / reach;
if (r <= 0)
return undefined; // would imply infinite time
const t = -tau * Math.log(r);
if (!Number.isFinite(t) || t < 0)
return undefined;
return t;
};
/**
* Create a stepper that yields a value at each elapsed time. Handoff from
* inertia to spring when crossing the bounds.
*
* @param initial Starting position and velocity on the axis.
* @param bounds Min/max boundaries for the axis.
* @param opts Physics parameters for decay and boundary spring.
* @returns A function that accepts elapsed time in ms and returns the current `StepResult`.
*
* @example
* ```ts
* const step = createInertiaToBoundary(
* { value: 50, velocity: 200 },
* { min: 0, max: 300 },
* { timeConstantMs: 350, restDelta: 0.5, restSpeed: 10, bounceStiffness: 500, bounceDamping: 25 }
* )
* const { value, done } = step(16) // advance 16 ms
* ```
*/
export const createInertiaToBoundary = (initial, bounds, opts) => {
const min = bounds.min;
const max = bounds.max;
const tauMs = Math.max(1, opts.timeConstantMs);
// Internal spring state
let mode = 'inertia';
let lastT = 0;
let x = initial.value;
let v = initial.velocity; // px/s
// Spring state values (activated post-handoff)
let springX = x;
let springV = 0;
let boundaryTarget = null;
// Starting OOB: skip inertia and engage the spring. Drop the
// away-from-boundary velocity component so the spring's first frames
// don't continue moving the value outward.
if (x < min || x > max) {
mode = 'spring';
springX = x;
springV = x < min ? Math.max(0, v) : Math.min(0, v);
boundaryTarget = x < min ? min : max;
}
// Precompute first crossing (if any) from initial state
const nearestBoundary = v < 0 ? min : max;
const tCross = mode === 'inertia'
? solveCrossTimeMs(initial.value, initial.velocity, tauMs, nearestBoundary)
: undefined;
const stepSpring = (dt) => {
// dt in seconds (will be called with small fixed steps for stability)
const stiffness = opts.bounceStiffness;
const damping = opts.bounceDamping;
// Hooke's law with simple semi-implicit Euler integration relative to the boundary
const target = boundaryTarget ?? (springX < min ? min : max);
const displacement = springX - target;
const force = -stiffness * displacement - damping * springV;
const accel = force; // mass = 1
springV += accel * dt;
springX += springV * dt;
};
return (tMs) => {
// Ensure monotonic time
if (tMs < lastT)
tMs = lastT;
const dtMs = tMs - lastT;
const dt = Math.min(0.1, Math.max(0.001, dtMs / 1000));
if (mode === 'inertia') {
if (tCross !== undefined && tMs >= tCross) {
// Hand off to spring exactly at crossing
const at = inertiaAt(initial.value, initial.velocity, tauMs, tCross);
boundaryTarget = nearestBoundary;
springX = at.x;
springV = at.v; // carry velocity continuity
mode = 'spring';
// Advance spring only for the remaining time after the handoff
const remainingMs = tMs - tCross;
// Perform small fixed substeps for numerical stability
let remaining = Math.max(0, remainingMs) / 1000;
while (remaining > 0) {
const h = Math.min(0.016, remaining);
stepSpring(h);
remaining -= h;
}
lastT = tMs;
const tgt = boundaryTarget ?? (springX < min ? min : max);
const done = Math.abs(springV) <= opts.restSpeed && Math.abs(springX - tgt) <= opts.restDelta;
const value = done ? tgt : springX;
if (done)
mode = 'done';
return { value, done };
}
else {
const at = inertiaAt(initial.value, initial.velocity, tauMs, tMs);
x = at.x;
v = at.v;
lastT = tMs;
const done = Math.abs(v) <= opts.restSpeed;
// If never crossing and slowed sufficiently, finish inertia
if (done && x >= min && x <= max) {
mode = 'done';
return { value: x, done: true };
}
return { value: x, done: false };
}
}
if (mode === 'spring') {
// Advance with fixed small substeps for numerical stability
let remaining = dt;
while (remaining > 0) {
const h = Math.min(0.016, remaining);
stepSpring(h);
remaining -= h;
}
lastT = tMs;
const tgt = boundaryTarget ?? (springX < min ? min : max);
const done = Math.abs(springV) <= opts.restSpeed && Math.abs(springX - tgt) <= opts.restDelta;
const value = done ? tgt : springX;
if (done)
mode = 'done';
return { value, done };
}
// done: return the last settled position from the correct regime
// If we ever engaged a spring (boundaryTarget set), use springX; otherwise use inertial x
const settled = boundaryTarget != null ? springX : x;
return { value: Math.min(max, Math.max(min, settled)), done: true };
};
};