@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
427 lines (426 loc) • 18.3 kB
JavaScript
import { cancelFrame, frame, frameData, isPrimaryPointer } from 'motion-dom';
/**
* Brand we stamp on already-wrapped handlers so passing them back through
* `wrapHandlers` (e.g. by a future middleware layer) doesn't double-defer
* — that would compound frame latency invisibly per wrap depth. Symbol
* scoping keeps it private to this module.
*/
const WRAPPED_BRAND = Symbol('svelte-motion:pan:wrapped');
const wrapUpdate = (handler, isAlive) => {
if (!handler)
return undefined;
if (handler[WRAPPED_BRAND])
return handler;
const wrapped = (event, info) => {
frame.update(() => {
// `isAlive` flips false on teardown; any frame.update closure
// queued before teardown but not yet flushed will see this and
// short-circuit — that's our cancellation path for the
// otherwise-uncancellable anonymous closures `frame.update`
// accepts.
if (!isAlive())
return;
handler(event, info);
}, false, true);
};
Object.defineProperty(wrapped, WRAPPED_BRAND, { value: true });
return wrapped;
};
const wrapPostRender = (handler, isAlive) => {
if (!handler)
return undefined;
if (handler[WRAPPED_BRAND])
return handler;
const wrapped = (event, info) => {
frame.postRender(() => {
if (!isAlive())
return;
handler(event, info);
});
};
Object.defineProperty(wrapped, WRAPPED_BRAND, { value: true });
return wrapped;
};
const wrapHandlers = (handlers, isAlive) => ({
onSessionStart: wrapUpdate(handlers.onSessionStart, isAlive),
onStart: wrapUpdate(handlers.onStart, isAlive),
onMove: wrapUpdate(handlers.onMove, isAlive),
onEnd: wrapPostRender(handlers.onEnd, isAlive),
onSessionEnd: wrapPostRender(handlers.onSessionEnd, isAlive)
});
const overflowStyles = new Set(['auto', 'scroll']);
const millisecondsToSeconds = (ms) => ms / 1000;
const secondsToMilliseconds = (s) => s * 1000;
const subtractPoint = (a, b) => ({
x: a.x - b.x,
y: a.y - b.y
});
const distance2D = (a, b) => Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2);
/**
* Compute velocity (px/s) from the history of timestamped points,
* looking back `timeDelta` seconds for stability. Matches upstream's
* `getVelocity` including the hold-then-flick safeguard (skip
* history[0] if it's > 2× timeDelta old AND there are alternatives).
*/
const getVelocity = (history, timeDelta) => {
if (history.length < 2)
return { x: 0, y: 0 };
let i = history.length - 1;
let timestampedPoint = null;
const lastPoint = history[history.length - 1];
while (i >= 0) {
timestampedPoint = history[i];
if (lastPoint.timestamp - timestampedPoint.timestamp > secondsToMilliseconds(timeDelta)) {
break;
}
i--;
}
if (!timestampedPoint)
return { x: 0, y: 0 };
if (timestampedPoint === history[0] &&
history.length > 2 &&
lastPoint.timestamp - timestampedPoint.timestamp > secondsToMilliseconds(timeDelta) * 2) {
timestampedPoint = history[1];
}
const time = millisecondsToSeconds(lastPoint.timestamp - timestampedPoint.timestamp);
if (time === 0)
return { x: 0, y: 0 };
const v = {
x: (lastPoint.x - timestampedPoint.x) / time,
y: (lastPoint.y - timestampedPoint.y) / time
};
if (v.x === Infinity)
v.x = 0;
if (v.y === Infinity)
v.y = 0;
return v;
};
const getPanInfo = (point, history) => ({
point,
delta: subtractPoint(point, history[history.length - 1]),
offset: subtractPoint(point, history[0]),
velocity: getVelocity(history, 0.1)
});
const extractEventPoint = (event) => ({
x: event.pageX,
y: event.pageY
});
/**
* Attach a pan gesture session to `el`. Returns a cleanup function that
* tears down the pointerdown listener and ends any in-flight session,
* with a `.update(next)` method for hot-swapping handlers mid-gesture.
*
* Internally a fresh `PanSession` spawns on each pointerdown — the
* outer attachment just keeps the pointerdown listener alive across the
* element's lifetime.
*
* SSR-safe: returns a no-op cleanup if `window` is undefined. The Svelte
* `$effect` consumer never fires on the server anyway, but defending the
* boundary lets the module load cleanly in node-only test runners.
*
* Lifecycle guarantee: when the returned cleanup runs mid-gesture, the
* session synthesizes `onEnd` + `onSessionEnd` against the raw handlers
* BEFORE removing listeners (see `PanSession.dispatchTerminal`). Hosts
* (e.g. `_MotionContainer`'s pan `$effect`) can put their `whilePan`
* revert logic inside the user-supplied `onEnd` and rely on it firing
* exactly once per gesture — whether the user released or the host
* forced teardown.
*
* @param el Target element to bind `pointerdown` on. Move/up/cancel
* events are listened for on the element's owning window so a fast
* swipe past the element's bounds keeps the gesture alive.
* @param handlers Pan lifecycle handlers. Any subset of
* `onSessionStart` (fires on pointerdown), `onStart` (fires the first
* time the cumulative offset crosses `distanceThreshold`), `onMove`
* (per-frame-throttled on every pointermove past threshold), `onEnd`
* (fires on pointerup/cancel if `onStart` ever fired), `onSessionEnd`
* (fires on every pointerup/cancel where a pointermove occurred).
* @param options Per-session config. `distanceThreshold` (default 3px)
* gates the start callback; `contextWindow` overrides the owning
* window (use for shadow-root / iframe scenarios).
* @returns A cleanup function with an attached `.update(next)` method.
* Calling the cleanup ends the session + removes the pointerdown
* listener. Calling `.update(next)` swaps handlers in place on the
* live session without rebuilding it — the canonical Svelte pattern
* for inline arrow handlers that change identity each render.
*
* @example
* ```ts
* const cleanup = attachPan(node, {
* onStart: (_event, info) => console.log('start', info.offset),
* onMove: (_event, info) => x.set(info.offset.x),
* onEnd: (_event, info) => {
* if (Math.abs(info.velocity.x) > 600) commit()
* else animate(x, 0, { type: 'spring' })
* }
* })
*
* // Later, swap handlers without ending the live gesture:
* cleanup.update({ onMove: (_e, info) => x.set(info.offset.x * 2) })
*
* // On unmount:
* cleanup()
* ```
*/
export const attachPan = (el, handlers, options = {}) => {
if (typeof window === 'undefined') {
const noop = () => { };
return Object.assign(noop, { update: () => { } });
}
const contextWindow = options.contextWindow ?? el.ownerDocument?.defaultView ?? window;
const distanceThreshold = options.distanceThreshold ?? 3;
let session = null;
let rawHandlers = handlers;
// Liveness flag the wrapped handler closures consult before invoking
// the user callback. Flips false at teardown so any frame.update /
// frame.postRender callbacks queued before teardown — but not yet
// flushed — see the flag and skip dispatch. This is our only way to
// cancel the anonymous closures the wrappers schedule (frame.update
// doesn't return a handle we can store per call).
let isAlive = true;
const aliveGuard = () => isAlive;
// Frame-scheduled mirror of the live handlers — onSessionStart / onStart /
// onMove are queued onto motion-dom's `update` step, onEnd / onSessionEnd
// onto `postRender`. This is the wrap upstream applies via `asyncHandler`
// + `frame.postRender` in PanGesture.createPanHandlers; see the
// wrapUpdate / wrapPostRender helpers at the top of this file for the
// rationale. PanSession itself stays scheduler-unaware (and synchronous,
// for testability) — the scheduling lives at the `attachPan` boundary.
let liveHandlers = wrapHandlers(handlers, aliveGuard);
const onPointerDown = (event) => {
// Match upstream: ignore non-primary pointers (multi-touch, right-click).
if (!isPrimaryPointer(event))
return;
// Defensively end any prior session before overwriting the reference.
// Without this, a second primary pointerdown that arrives before the
// first pointerup orphans the prior session's contextWindow listeners.
session?.end();
session = new PanSession(event, liveHandlers, {
distanceThreshold,
contextWindow,
element: el
});
};
el.addEventListener('pointerdown', onPointerDown);
const update = (next) => {
rawHandlers = next;
liveHandlers = wrapHandlers(next, aliveGuard);
session?.updateHandlers(liveHandlers);
};
const teardown = () => {
// Synthesize the gesture's terminal lifecycle BEFORE flipping
// `isAlive`, so a host that tears us down mid-pan (effect re-run,
// component unmount) still sees a balanced onPanEnd / onPanSessionEnd
// pair. Dispatched against the *raw* (unwrapped) handlers so the
// delivery is synchronous — the wrapped lane would otherwise queue
// the callbacks onto frame.postRender just for them to be cancelled
// by the `isAlive = false` line immediately below.
if (session) {
session.dispatchTerminal(rawHandlers);
session.end();
session = null;
}
isAlive = false;
el.removeEventListener('pointerdown', onPointerDown);
};
return Object.assign(teardown, { update });
};
class PanSession {
history = [];
startEvent = null;
lastMoveEvent = null;
lastMovePoint = null;
handlers = {};
contextWindow = window;
distanceThreshold = 3;
element = null;
scrollPositions = new Map();
/**
* Idempotency flag — set the first time the gesture's terminal
* lifecycle pair (`onEnd` + `onSessionEnd`) fires. Both
* `handlePointerUp` (the natural release path) and
* `dispatchTerminal` (the forced-teardown path called by
* `attachPan.teardown`) check this and bail if already dispatched.
* Without it, a normal pointerup followed by a host-side teardown
* (e.g. `$effect` cleanup, component unmount) would replay
* `onEnd`/`onSessionEnd` against handlers that already saw them.
*/
terminalDispatched = false;
removeScrollListeners = null;
removeListeners = null;
constructor(event, handlers, opts) {
// Bail on non-primary pointers. Properties keep their declared
// defaults so TypeScript sees them initialized regardless of which
// constructor branch ran.
if (!isPrimaryPointer(event))
return;
this.handlers = handlers;
this.contextWindow = opts.contextWindow;
this.distanceThreshold = opts.distanceThreshold;
this.element = opts.element;
const point = extractEventPoint(event);
this.history = [{ ...point, timestamp: frameData.timestamp }];
this.handlers.onSessionStart?.(event, getPanInfo(point, this.history));
const moveHandler = (e) => this.handlePointerMove(e);
const upHandler = (e) => this.handlePointerUp(e);
this.contextWindow.addEventListener('pointermove', moveHandler);
this.contextWindow.addEventListener('pointerup', upHandler);
this.contextWindow.addEventListener('pointercancel', upHandler);
this.removeListeners = () => {
this.contextWindow.removeEventListener('pointermove', moveHandler);
this.contextWindow.removeEventListener('pointerup', upHandler);
this.contextWindow.removeEventListener('pointercancel', upHandler);
};
if (this.element)
this.startScrollTracking(this.element);
}
updateHandlers(handlers) {
this.handlers = handlers;
}
end() {
this.removeListeners?.();
this.removeListeners = null;
this.removeScrollListeners?.();
this.removeScrollListeners = null;
this.scrollPositions.clear();
cancelFrame(this.updatePoint);
}
/**
* Synthesize the gesture's terminal lifecycle pair (`onEnd` then
* `onSessionEnd`) against the supplied *raw* (unwrapped) handlers,
* using the last observed event + point as the synthetic terminal
* sample. Called by `attachPan.teardown` when a host kills the
* session mid-gesture — without this, an `$effect` re-run that
* tears down the attachment silently strands the consumer's state
* machine in an "in-progress" state (whilePan keyframes never
* revert, threshold-based commit decisions never run).
*
* Bypasses the frame-loop wrappers deliberately: the wrapped
* handlers would queue to `frame.postRender` only for the
* about-to-flip `isAlive` flag in attachPan to cancel them. Raw
* dispatch keeps the lifecycle synchronous with teardown.
*
* No-op when no pointermove ever fired — matches the
* `handlePointerUp` no-movement contract upstream uses.
*/
dispatchTerminal(rawHandlers) {
if (this.terminalDispatched)
return;
if (!(this.lastMoveEvent && this.lastMovePoint))
return;
const info = getPanInfo(this.lastMovePoint, this.history);
if (this.startEvent)
rawHandlers.onEnd?.(this.lastMoveEvent, info);
rawHandlers.onSessionEnd?.(this.lastMoveEvent, info);
this.terminalDispatched = true;
}
handlePointerMove = (event) => {
this.lastMoveEvent = event;
this.lastMovePoint = extractEventPoint(event);
// Per-frame throttle so a 1000hz mouse doesn't drown handlers.
frame.update(this.updatePoint, true);
};
handlePointerUp = (event) => {
this.end();
if (this.terminalDispatched)
return;
if (!(this.lastMoveEvent && this.lastMovePoint)) {
// No pointermove ever fired — match upstream framer-motion
// (`packages/framer-motion/src/gestures/pan/PanSession.ts`
// ~line 320) and return WITHOUT firing onEnd / onSessionEnd.
// Consumers that want a "tap" signal should use the press /
// tap gesture instead. This prevents a spurious
// onPanSessionStart → onPanSessionEnd pair on every plain
// click of a pan-enabled element.
return;
}
const finalPoint = event.type === 'pointercancel' ? this.lastMovePoint : extractEventPoint(event);
const info = getPanInfo(finalPoint, this.history);
if (this.startEvent)
this.handlers.onEnd?.(event, info);
this.handlers.onSessionEnd?.(event, info);
// Mark idempotent so a later forced teardown via
// `dispatchTerminal` doesn't replay this pair.
this.terminalDispatched = true;
};
updatePoint = () => {
if (!(this.lastMoveEvent && this.lastMovePoint))
return;
const info = getPanInfo(this.lastMovePoint, this.history);
const panAlreadyStarted = this.startEvent !== null;
const pastThreshold = distance2D(info.offset, { x: 0, y: 0 }) >= this.distanceThreshold;
if (!panAlreadyStarted && !pastThreshold)
return;
this.history.push({ ...this.lastMovePoint, timestamp: frameData.timestamp });
if (!panAlreadyStarted) {
this.handlers.onStart?.(this.lastMoveEvent, info);
this.startEvent = this.lastMoveEvent;
}
this.handlers.onMove?.(this.lastMoveEvent, info);
};
/**
* Track scrollable ancestors so we can compensate for scroll deltas
* during the gesture — mirrors upstream's `startScrollTracking`.
* For element scrolls: adjust `history[0]` so offset stays sane
* (pageX/pageY unaffected by element scroll). For window scrolls:
* adjust `lastMovePoint` (pageX/pageY shift with window scroll).
*/
startScrollTracking(element) {
let current = element.parentElement;
while (current) {
const style = getComputedStyle(current);
if (overflowStyles.has(style.overflowX) || overflowStyles.has(style.overflowY)) {
this.scrollPositions.set(current, {
x: current.scrollLeft,
y: current.scrollTop
});
}
current = current.parentElement;
}
this.scrollPositions.set(this.contextWindow, {
x: this.contextWindow.scrollX,
y: this.contextWindow.scrollY
});
const onElementScroll = (event) => {
this.handleScroll(event.target);
};
const onWindowScroll = () => {
this.handleScroll(this.contextWindow);
};
this.contextWindow.addEventListener('scroll', onElementScroll, { capture: true });
this.contextWindow.addEventListener('scroll', onWindowScroll);
this.removeScrollListeners = () => {
this.contextWindow.removeEventListener('scroll', onElementScroll, {
capture: true
});
this.contextWindow.removeEventListener('scroll', onWindowScroll);
};
}
handleScroll(target) {
const initial = this.scrollPositions.get(target);
if (!initial)
return;
const isWindow = target === this.contextWindow;
const current = isWindow
? { x: this.contextWindow.scrollX, y: this.contextWindow.scrollY }
: {
x: target.scrollLeft,
y: target.scrollTop
};
const delta = { x: current.x - initial.x, y: current.y - initial.y };
if (delta.x === 0 && delta.y === 0)
return;
if (isWindow) {
if (this.lastMovePoint) {
this.lastMovePoint.x += delta.x;
this.lastMovePoint.y += delta.y;
}
}
else if (this.history.length > 0) {
this.history[0].x -= delta.x;
this.history[0].y -= delta.y;
}
this.scrollPositions.set(target, current);
frame.update(this.updatePoint, true);
}
}