@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
291 lines (290 loc) • 8.64 kB
JavaScript
import { HTMLProjectionNode, HTMLVisualElement, copyBoxInto, createBox, visualElementStore } from 'motion-dom';
const createVisualState = () => ({
latestValues: {},
renderState: {
transform: {},
transformOrigin: {},
style: {},
vars: {}
}
});
const cloneMeasurements = (measurements) => {
if (!measurements)
return undefined;
const measuredBox = createBox();
const layoutBox = createBox();
copyBoxInto(measuredBox, measurements.measuredBox);
copyBoxInto(layoutBox, measurements.layoutBox);
return {
animationId: measurements.animationId,
measuredBox,
layoutBox,
latestValues: { ...measurements.latestValues },
source: measurements.source
};
};
const animationTypes = new Set([
'position',
'x',
'y',
'size',
'both',
'preserve-aspect'
]);
const animationTypeForLayout = (layout) => typeof layout === 'string' && animationTypes.has(layout)
? layout
: 'both';
/**
* Svelte lifecycle adapter for motion-dom's upstream projection node system.
*
* The public Svelte API stays unchanged (`layout`, `layoutId`, `transition`).
* This adapter only translates those props into the same `HTMLProjectionNode`
* and `HTMLVisualElement` internals Framer Motion uses.
*/
export class MotionDomProjectionAdapter {
static adapters = new WeakMap();
visualElement;
projection;
element = null;
layout;
layoutId;
transition;
lastLayout;
constructor(options = {}) {
const parent = options.parent ?? null;
this.visualElement = new HTMLVisualElement({
parent: parent?.visualElement,
props: {},
presenceContext: null,
visualState: createVisualState()
}, { allowProjection: true });
this.projection = new HTMLProjectionNode(this.visualElement.latestValues, parent?.projection);
this.visualElement.projection = this.projection;
MotionDomProjectionAdapter.adapters.set(this.projection, this);
}
/**
* Update projection options from current Svelte props.
*
* @param options Current layout-related motion props.
* @returns Nothing.
*
* @example
* ```ts
* adapter.updateOptions({ layout, layoutId, transition, style })
* ```
*/
updateOptions(options) {
this.layout = options.layout;
this.layoutId = options.layoutId;
this.transition = options.transition;
this.visualElement.update({
transition: options.transition,
style: options.style
}, null);
this.projection.setOptions({
layout: options.layout,
layoutId: options.layoutId,
layoutScroll: options.layoutScroll,
animationType: animationTypeForLayout(options.layout),
transition: options.transition,
visualElement: this.visualElement
});
}
/**
* Mount the upstream projection node to an element and seed its layout.
*
* @param element Element represented by the current motion component.
* @returns Nothing.
*
* @example
* ```ts
* adapter.mount(element)
* ```
*/
mount(element) {
if (this.element === element)
return;
if (this.element)
this.unmount();
this.element = element;
MotionDomProjectionAdapter.adapters.set(this.projection, this);
this.visualElement.mount(element);
this.seedLayout();
}
/**
* Unmount the upstream projection node and clear its visual-element store.
*
* @returns Nothing.
*
* @example
* ```ts
* adapter.unmount()
* ```
*/
unmount() {
if (!this.element)
return;
const element = this.element;
this.projection.scheduleCheckAfterUnmount();
this.visualElement.unmount();
visualElementStore.delete(element);
MotionDomProjectionAdapter.adapters.delete(this.projection);
this.element = null;
this.lastLayout = undefined;
}
/**
* Capture the upstream "before" snapshot.
*
* @returns Nothing.
*
* @example
* ```ts
* adapter.willUpdate()
* ```
*/
willUpdate() {
if (!this.element || !this.layout)
return;
this.projection.willUpdate();
}
/**
* Commit an upstream layout update after Svelte has patched the DOM.
*
* @returns Nothing.
*
* @example
* ```ts
* adapter.didUpdate()
* ```
*/
didUpdate() {
if (!this.element || !this.layout)
return;
this.projection.root?.didUpdate();
this.refreshCachedLayout();
}
/**
* Seed the current layout without animating.
*
* @returns Nothing.
*
* @example
* ```ts
* adapter.seedLayout()
* ```
*/
seedLayout() {
if (!this.element)
return;
this.updatePathScroll();
this.projection.isLayoutDirty = true;
this.projection.updateLayout();
this.lastLayout = cloneMeasurements(this.projection.layout);
}
/**
* Animate from the last cached layout to the current observed layout.
*
* This covers layout changes discovered after the mutation by observers.
* Svelte runes mode doesn't expose the same component pre/post-update
* hooks Framer Motion uses in React, so this adapter reuses upstream
* projection while the Svelte component controls the snapshot timing.
*
* @returns Nothing.
*
* @example
* ```ts
* adapter.commitObservedLayoutChange()
* ```
*/
commitObservedLayoutChange() {
if (!this.element || !this.layout)
return;
if (!this.lastLayout) {
this.seedLayout();
return;
}
this.projection.root?.startUpdate();
this.seedCachedSnapshotsForSubtree(this.projection);
this.projection.root?.didUpdate();
this.refreshCachedLayout();
}
/**
* Finish any active upstream layout animation in this subtree.
*
* @returns Nothing.
*
* @example
* ```ts
* adapter.finishAnimation()
* ```
*/
finishAnimation() {
if (!this.element || !this.layout)
return;
this.finishAnimationForSubtree(this.projection);
this.seedLayout();
}
/**
* Check whether this projection subtree has an active layout animation.
*
* @returns `true` when this projection subtree is currently animating.
*
* @example
* ```ts
* if (adapter.isAnimating()) adapter.finishAnimation()
* ```
*/
isAnimating() {
return this.isAnimatingSubtree(this.projection);
}
seedCachedSnapshotsForSubtree(projection) {
const adapter = MotionDomProjectionAdapter.adapters.get(projection);
const snapshot = cloneMeasurements(adapter?.lastLayout);
if (snapshot && projection.options.layout) {
this.prepareSnapshotPath(projection);
projection.snapshot = snapshot;
projection.isLayoutDirty = true;
}
for (const child of projection.children) {
this.seedCachedSnapshotsForSubtree(child);
}
}
prepareSnapshotPath(projection) {
projection.root.hasTreeAnimated = true;
for (const node of projection.path) {
node.shouldResetTransform = true;
node.updateScroll('snapshot');
if (node.options.layoutRoot) {
node.willUpdate(false);
}
}
}
finishAnimationForSubtree(projection) {
projection.finishAnimation();
projection.targetDelta = projection.relativeTarget = projection.target = undefined;
projection.isProjectionDirty = true;
projection.scheduleRender();
for (const child of projection.children) {
this.finishAnimationForSubtree(child);
}
}
isAnimatingSubtree(projection) {
if (projection.currentAnimation)
return true;
for (const child of projection.children) {
if (this.isAnimatingSubtree(child))
return true;
}
return false;
}
updatePathScroll() {
for (const node of this.projection.path) {
node.updateScroll();
}
}
refreshCachedLayout() {
requestAnimationFrame(() => {
this.lastLayout = cloneMeasurements(this.projection.layout);
});
}
}