@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
312 lines (311 loc) • 12.5 kB
JavaScript
import { animate } from 'motion';
const layoutSizeAnimationAttribute = 'data-layout-size-animation';
const roundedPx = (value) => `${Math.max(0, Math.round(value))}px`;
const mix = (from, to, progress) => from + (to - from) * progress;
const isViewportOffscreen = (el) => {
if (typeof window === 'undefined')
return false;
const rect = el.getBoundingClientRect();
return (rect.bottom <= 0 ||
rect.right <= 0 ||
rect.top >= window.innerHeight ||
rect.left >= window.innerWidth);
};
const runBoxSizeAnimation = (el, transforms, transition) => {
const { dx, dy, sx, sy } = transforms;
const originalWidth = el.style.width;
const originalHeight = el.style.height;
const originalTransform = el.style.transform;
const originalTransformOrigin = el.style.transformOrigin;
const nextRect = el.getBoundingClientRect();
const prevWidth = nextRect.width * sx;
const prevHeight = nextRect.height * sy;
el.setAttribute(layoutSizeAnimationAttribute, 'true');
for (const child of el.querySelectorAll('[data-svelte-motion-layout]')) {
child.style.transform = '';
child.style.transformOrigin = '';
if (child.style.willChange === 'transform')
child.style.willChange = '';
}
el.style.width = roundedPx(prevWidth);
el.style.height = roundedPx(prevHeight);
const sizedRect = el.getBoundingClientRect();
const residualDx = nextRect.left + dx - sizedRect.left;
const residualDy = nextRect.top + dy - sizedRect.top;
const shouldTranslate = Math.abs(residualDx) > 0.5 || Math.abs(residualDy) > 0.5;
if (shouldTranslate) {
el.style.transformOrigin = '0 0';
el.style.transform = `translate(${Math.round(residualDx)}px, ${Math.round(residualDy)}px)`;
}
const writeBox = (progress) => {
el.style.width = roundedPx(mix(prevWidth, nextRect.width, progress));
el.style.height = roundedPx(mix(prevHeight, nextRect.height, progress));
if (shouldTranslate) {
const x = Math.round(mix(residualDx, 0, progress));
const y = Math.round(mix(residualDy, 0, progress));
el.style.transform = x === 0 && y === 0 ? '' : `translate(${x}px, ${y}px)`;
}
};
const animation = animate(0, 1, {
...transition,
onUpdate: writeBox
});
let removeScrollListener;
let offscreenRaf = null;
let cleanupRan = false;
const cleanup = () => {
if (cleanupRan)
return;
cleanupRan = true;
removeScrollListener?.();
if (offscreenRaf !== null &&
typeof window !== 'undefined' &&
typeof window.cancelAnimationFrame === 'function') {
window.cancelAnimationFrame(offscreenRaf);
offscreenRaf = null;
}
el.style.width = originalWidth;
el.style.height = originalHeight;
el.style.transformOrigin = originalTransformOrigin;
el.style.transform = originalTransform;
el.removeAttribute(layoutSizeAnimationAttribute);
};
if (typeof window !== 'undefined') {
const completeIfOffscreen = () => {
if (cleanupRan)
return;
if (isViewportOffscreen(el)) {
animation.complete();
cleanup();
}
};
const scheduleCompleteIfOffscreen = () => {
if (typeof window.requestAnimationFrame !== 'function') {
completeIfOffscreen();
return;
}
if (offscreenRaf !== null)
return;
offscreenRaf = window.requestAnimationFrame(() => {
offscreenRaf = null;
completeIfOffscreen();
});
};
const handleScroll = () => {
completeIfOffscreen();
scheduleCompleteIfOffscreen();
};
window.addEventListener('scroll', handleScroll, { passive: true });
removeScrollListener = () => {
window.removeEventListener('scroll', handleScroll);
};
completeIfOffscreen();
scheduleCompleteIfOffscreen();
}
animation.finished?.finally(cleanup);
};
/**
* Measure an element's bounding client rect without current transform.
*
* Temporarily clears `transform` to avoid skewing measurements, restoring it
* immediately after reading the rect.
*
* When `scrollContainers` are provided, the returned rect is shifted by the
* **sum** of each container's `scrollLeft` / `scrollTop`. When
* `includeViewportScroll` is true, the viewport's `window.scrollX` /
* `window.scrollY` is included too. FLIP deltas computed from two such
* measures stay correct even when the user scrolls between measurements —
* including a nested `layoutScroll` inside another `layoutScroll`. Mirrors
* framer-motion's `removeElementScroll`, which walks every ancestor in the
* path, plus root scroll compensation from the projection tree.
*
* Pass an empty array (or omit) for viewport-relative behaviour.
*
* `baseTransform` is the value the element's `transform` is set to while
* measuring (default `'none'`, i.e. all transforms removed). The
* projection system passes the element's mount-time transform here so
* that a user-authored static `transform` is preserved in the
* measurement while only the motion-applied portion (written after
* mount) is removed — mirroring framer-motion's `removeBoxTransforms`,
* which only subtracts motion-tracked `latestValues` and leaves
* user-authored transforms intact. Existing FLIP callers omit it and
* get the original strip-everything behaviour.
*
* @param el Element to measure.
* @param scrollContainers Optional ancestor chain with `layoutScroll` enabled.
* @param baseTransform Transform string applied during measurement. Defaults to `'none'`.
* @param includeViewportScroll Whether to include `window.scrollX/Y` in the returned rect.
* @returns DOMRect snapshot of the element.
*
* @example
* ```ts
* // No scroll containers — viewport-relative rect.
* const rect = measureRect(node)
*
* // Single ancestor scroll container (one `layoutScroll`).
* const rect = measureRect(node, [scrollPanel])
*
* // Nested `layoutScroll` ancestors — sums offsets from every container.
* const rect = measureRect(node, [innerScroll, outerScroll])
* ```
*/
export const measureRect = (el, scrollContainers, baseTransform = 'none', includeViewportScroll = false) => {
const prev = el.style.transform;
try {
el.style.transform = baseTransform;
const rect = el.getBoundingClientRect();
let offsetLeft = includeViewportScroll && typeof window !== 'undefined' ? window.scrollX : 0;
let offsetTop = includeViewportScroll && typeof window !== 'undefined' ? window.scrollY : 0;
if (!scrollContainers || scrollContainers.length === 0) {
if (offsetLeft === 0 && offsetTop === 0)
return rect;
return new DOMRect(rect.left + offsetLeft, rect.top + offsetTop, rect.width, rect.height);
}
// Re-express the rect in the *combined* scroll-container coordinate
// space so a subsequent scroll on any of them doesn't show up as
// movement. DOMRect's left/top are read-only, so allocate a fresh
// one with the summed offsets applied.
for (const container of scrollContainers) {
offsetLeft += container.scrollLeft;
offsetTop += container.scrollTop;
}
return new DOMRect(rect.left + offsetLeft, rect.top + offsetTop, rect.width, rect.height);
}
finally {
el.style.transform = prev;
}
};
/**
* Compute FLIP transform deltas between two rects.
*
* @param prev Previous rect.
* @param next Next rect.
* @param mode `true` for translate+scale, `'position'` for translate only.
* @return Deltas and flags indicating which transforms to apply.
*/
export const computeFlipTransforms = (prev, next, mode) => {
const dx = prev.left - next.left;
const dy = prev.top - next.top;
const sx = next.width > 0 ? prev.width / next.width : 1;
const sy = next.height > 0 ? prev.height / next.height : 1;
const shouldTranslate = Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5;
const shouldScale = mode !== 'position' && (Math.abs(1 - sx) > 0.01 || Math.abs(1 - sy) > 0.01);
return { dx, dy, sx, sy, shouldTranslate, shouldScale };
};
/**
* Run a FLIP animation for the provided deltas.
*
* Pre-applies the inverse transform to avoid layout flashes, then animates back
* to identity using the provided transition.
*
* @param el Target element.
* @param transforms Deltas computed by `computeFlipTransforms`.
* @param transition Timing/options for the animation.
*/
export const runFlipAnimation = (el, transforms, transition) => {
const { dx, dy, sx, sy, shouldTranslate, shouldScale } = transforms;
if (!(shouldTranslate || shouldScale))
return;
const correctionTargets = shouldScale
? Array.from(el.querySelectorAll('[data-svelte-motion-layout]'))
: [];
if (shouldScale && correctionTargets.length > 0) {
runBoxSizeAnimation(el, { dx, dy, sx, sy }, transition);
return;
}
const keyframes = {};
if (shouldTranslate) {
keyframes.x = [dx, 0];
keyframes.y = [dy, 0];
}
if (shouldScale) {
keyframes.scaleX = [sx, 1];
keyframes.scaleY = [sy, 1];
}
const parts = [];
if (shouldTranslate)
parts.push(`translate(${dx}px, ${dy}px)`);
if (shouldScale)
parts.push(`scale(${sx}, ${sy})`);
el.style.transformOrigin = '0 0';
el.style.transform = parts.join(' ');
animate(el, keyframes, transition);
};
/**
* Toggle compositor hints for smoother transform animations.
*
* @param el Target element.
* @param enabled Whether to enable compositor hints.
*/
export const setCompositorHints = (el, enabled) => {
el.style.willChange = enabled ? 'transform' : '';
el.style.transformOrigin = enabled ? '0 0' : '';
if (!enabled)
el.style.transform = '';
};
/**
* Observe size/attribute changes that commonly trigger layout changes.
*
* Returns a cleanup function that disconnects observers. The callback is called
* for resize events and attribute/class/style changes on the element and
* immediate parent child-list changes.
*
* @param el Element to observe.
* @param onChange Callback invoked when a relevant change is detected.
* @return Cleanup function.
*/
export const observeLayoutChanges = (el, onChange) => {
let pendingRaf = null;
let releaseTimeout = null;
const schedule = () => {
if (el.closest(`[${layoutSizeAnimationAttribute}]`)) {
el.style.transform = '';
el.style.transformOrigin = '';
if (el.style.willChange === 'transform')
el.style.willChange = '';
return;
}
if (pendingRaf !== null || releaseTimeout !== null)
return;
// Leading-edge: call immediately, then throttle further calls until next frame (or 50ms)
onChange();
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
pendingRaf = window.requestAnimationFrame(() => {
pendingRaf = null;
});
}
else {
releaseTimeout = setTimeout(() => {
releaseTimeout = null;
}, 50);
}
};
const ro = new ResizeObserver(() => schedule());
ro.observe(el);
const attributeObserver = new MutationObserver(() => schedule());
attributeObserver.observe(el, {
attributes: true,
attributeFilter: ['class', 'data-presence-layout-hold']
});
const childListObserver = new MutationObserver(() => schedule());
childListObserver.observe(el, {
childList: true,
subtree: true
});
if (el.parentElement) {
childListObserver.observe(el.parentElement, { childList: true, subtree: false });
}
return () => {
ro.disconnect();
attributeObserver.disconnect();
childListObserver.disconnect();
if (pendingRaf !== null && typeof cancelAnimationFrame === 'function') {
cancelAnimationFrame(pendingRaf);
pendingRaf = null;
}
if (releaseTimeout !== null) {
clearTimeout(releaseTimeout);
releaseTimeout = null;
}
};
};