@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.1 kB
JavaScript
import { mergeInlineStyles } from './style';
import { resolveRestingValues } from './variants';
import { startWaapiAnimation } from 'motion';
import { mapEasingToNativeEasing, optimizedAppearDataAttribute, optimizedAppearDataId, transformProps } from 'motion-dom';
const appearStoreId = (elementId, valueName) => {
const key = transformProps.has(valueName) ? 'transform' : valueName;
return `${elementId}: ${key}`;
};
const getAppearStore = () => {
if (typeof window === 'undefined')
return undefined;
window.__SvelteMotionAppear ??= {
animations: new Map(),
complete: new Map(),
started: []
};
return window.__SvelteMotionAppear;
};
const installAppearGlobals = () => {
if (typeof window === 'undefined')
return;
const store = getAppearStore();
if (!store)
return;
window.MotionHasOptimisedAnimation ??= (elementId, valueName) => {
if (!elementId)
return false;
if (!valueName)
return store.complete.has(elementId);
return store.animations.has(appearStoreId(elementId, valueName));
};
window.MotionHandoffMarkAsComplete ??= (elementId) => {
if (store.complete.has(elementId)) {
store.complete.set(elementId, true);
}
};
window.MotionHandoffIsComplete ??= (elementId) => {
return store.complete.get(elementId) === true;
};
window.MotionCancelOptimisedAnimation ??= (elementId, valueName) => {
if (!elementId || !valueName)
return;
const animationId = appearStoreId(elementId, valueName);
const data = store.animations.get(animationId);
if (!data)
return;
data.animation.cancel();
store.animations.delete(animationId);
if (!store.animations.size) {
window.MotionCancelOptimisedAnimation = undefined;
}
};
};
const readStyleProp = (style, prop) => {
return style
.split(';')
.map((part) => part.trim())
.find((part) => part.startsWith(`${prop}:`))
?.slice(prop.length + 1)
.trim();
};
const toNativeOptions = (transition) => {
const duration = typeof transition?.duration === 'number' ? transition.duration : 0.3;
const delay = typeof transition?.delay === 'number' ? transition.delay : 0;
const durationMs = duration * 1000;
const options = {
duration: durationMs,
delay: delay * 1000,
fill: 'both'
};
const easing = mapEasingToNativeEasing(transition?.ease, durationMs);
if (Array.isArray(easing)) {
options.easing = easing[0] ?? 'linear';
}
else if (easing) {
options.easing = easing;
}
return options;
};
/**
* Build serialisable optimized-appear animation entries from an initial and
* animate pair.
*
* @param initial Initial keyframes reflected into SSR markup.
* @param animate Target keyframes for the enter animation.
* @param transition Motion transition options.
* @returns Appear entries for WAAPI-supported opacity and transform values.
*
* @example
* ```ts
* const entries = createOptimizedAppearData(
* { opacity: 0, scale: 0.8 },
* { opacity: 1, scale: 1 },
* { duration: 0.6, ease: [0.16, 1, 0.3, 1] }
* )
* ```
*/
export const createOptimizedAppearData = (initial, animate, transition) => {
if (!initial || !animate)
return [];
const target = resolveRestingValues(animate);
if (!target)
return [];
const options = toNativeOptions(transition);
const entries = [];
if (initial.opacity != null && target.opacity != null) {
entries.push({
name: 'opacity',
keyframes: [
Array.isArray(initial.opacity) ? initial.opacity[0] : initial.opacity,
Array.isArray(target.opacity) ? target.opacity[0] : target.opacity
],
options
});
}
const initialTransform = readStyleProp(mergeInlineStyles('', initial), 'transform');
const targetTransform = readStyleProp(mergeInlineStyles('', target), 'transform');
if (initialTransform && targetTransform && initialTransform !== targetTransform) {
entries.push({
name: 'transform',
keyframes: [initialTransform, targetTransform],
options
});
}
return entries;
};
/**
* Create the inline SSR bootstrap that starts appear animations before Svelte
* hydrates the component tree.
*
* @param appearId Stable optimized-appear id attached to the motion element.
* @param entries WAAPI animation entries to start.
* @returns A script tag string, or an empty string when no entries exist.
*
* @example
* ```ts
* const script = createOptimizedAppearScript('appear-1', [
* { name: 'opacity', keyframes: [0, 1], options: { duration: 300, fill: 'both' } }
* ])
* ```
*/
export const createOptimizedAppearScript = (appearId, entries) => {
if (!appearId || entries.length === 0)
return '';
const payload = JSON.stringify({ id: appearId, entries }).replace(/</g, '\\u003c');
return `<script>(()=>{const p=${payload},w=window;if(w.MotionIsMounted)return;const q=String(p.id).replace(/["\\\\]/g,"\\\\$&");const e=document.querySelector('[${optimizedAppearDataAttribute}="'+q+'"]');if(!e||!e.animate)return;const s=w.__SvelteMotionAppear||(w.__SvelteMotionAppear={animations:new Map,complete:new Map,started:[]});const k=(id,n)=>id+": "+(n==="transform"?"transform":n);w.MotionHasOptimisedAnimation=w.MotionHasOptimisedAnimation||((id,n)=>id?n?s.animations.has(k(id,n)):s.complete.has(id):false);w.MotionHandoffMarkAsComplete=w.MotionHandoffMarkAsComplete||((id)=>{if(s.complete.has(id))s.complete.set(id,true)});w.MotionHandoffIsComplete=w.MotionHandoffIsComplete||((id)=>s.complete.get(id)===true);w.MotionCancelOptimisedAnimation=w.MotionCancelOptimisedAnimation||((id,n)=>{const key=k(id,n),d=s.animations.get(key);if(!d)return;d.animation.cancel();s.animations.delete(key);if(!s.animations.size)w.MotionCancelOptimisedAnimation=undefined});s.complete.set(p.id,false);for(const a of p.entries){const key=k(p.id,a.name);if(!s.readyAnimation){s.readyAnimation=e.animate({[a.name]:[a.keyframes[0],a.keyframes[0]]},{duration:1e4,easing:"linear",fill:"both"});s.animations.set(key,{animation:s.readyAnimation,startTime:null})}const start=()=>{s.readyAnimation.cancel();let t=s.startFrameTime;if(t===undefined){t=performance.now();s.startFrameTime=t}const anim=e.animate({[a.name]:a.keyframes},a.options);anim.startTime=t;s.animations.set(key,{animation:anim,startTime:t});s.started.push({id:p.id,name:a.name})};const r=s.readyAnimation;r.ready?r.ready.then(start).catch(()=>{}):start()}})();</script>`;
};
/**
* Start an optimized appear animation imperatively.
*
* Mirrors Framer Motion's `startOptimizedAppearAnimation`: if Motion has
* already mounted, this intentionally does nothing.
*
* @param element Element carrying `data-framer-appear-id`.
* @param name CSS property to animate.
* @param keyframes WAAPI keyframes for the property.
* @param options Motion animation options.
* @param onReady Optional callback receiving the started animation.
* @returns Nothing.
*
* @example
* ```ts
* const element = document.querySelector('[data-framer-appear-id]')
* if (element instanceof HTMLElement) {
* startOptimizedAppearAnimation(element, 'opacity', [0, 1], { duration: 0.3 })
* }
* ```
*/
export const startOptimizedAppearAnimation = (element, name, keyframes, options, onReady) => {
if (typeof window === 'undefined' || window.MotionIsMounted)
return;
const id = element.dataset[optimizedAppearDataId];
if (!id)
return;
installAppearGlobals();
const store = getAppearStore();
if (!store)
return;
const storeId = appearStoreId(id, name);
if (!store.readyAnimation) {
store.readyAnimation = startWaapiAnimation(element, name, [keyframes[0], keyframes[0]], {
duration: 10000,
ease: 'linear'
});
store.animations.set(storeId, { animation: store.readyAnimation, startTime: null });
}
const startAnimation = () => {
store.readyAnimation?.cancel();
store.startFrameTime ??= performance.now();
const animation = startWaapiAnimation(element, name, keyframes, options);
animation.startTime = store.startFrameTime;
store.animations.set(storeId, { animation, startTime: store.startFrameTime });
store.started.push({ id, name });
onReady?.(animation);
};
store.complete.set(id, false);
const readyAnimation = store.readyAnimation;
if (readyAnimation.ready) {
readyAnimation.ready.then(startAnimation).catch(() => { });
}
else {
startAnimation();
}
};
/**
* Commit and cancel optimized appear animations for an element.
*
* @param elementId Optimized appear id.
* @returns `true` when at least one optimized animation was handed off.
*
* @example
* ```ts
* const wasHandedOff = handoffOptimizedAppearAnimation('appear-1')
* if (wasHandedOff) {
* console.log('Animation handed off to runtime')
* }
* ```
*/
export const handoffOptimizedAppearAnimation = (elementId) => {
if (!elementId || typeof window === 'undefined')
return false;
const store = getAppearStore();
if (!store)
return false;
let handedOff = false;
for (const [key, data] of [...store.animations]) {
if (!key.startsWith(`${elementId}: `))
continue;
data.animation.commitStyles?.();
data.animation.cancel();
store.animations.delete(key);
handedOff = true;
}
if (store.complete.has(elementId)) {
store.complete.set(elementId, true);
}
return handedOff;
};
/**
* Let active optimized appear animations finish before handing their final
* styles back to Svelte Motion.
*
* @param elementId Optimized appear id.
* @returns Whether at least one optimized animation was adopted.
*
* @example
* ```ts
* const wasAdopted = await finishOptimizedAppearAnimation('appear-1')
* if (wasAdopted) {
* console.log('Animation finished and adopted')
* }
* ```
*/
export const finishOptimizedAppearAnimation = async (elementId) => {
if (!elementId || typeof window === 'undefined')
return false;
const store = getAppearStore();
if (!store)
return false;
let entries = [...store.animations].filter(([key]) => key.startsWith(`${elementId}: `));
if (!entries.length)
return false;
await Promise.all(entries.map(([, data]) => data.startTime === null ? data.animation.ready?.catch(() => undefined) : undefined));
entries = [...store.animations].filter(([key]) => key.startsWith(`${elementId}: `));
await Promise.all(entries.map(([, data]) => data.animation.finished.catch(() => undefined)));
for (const [key, data] of entries) {
if (!store.animations.has(key))
continue;
data.animation.commitStyles?.();
data.animation.cancel();
store.animations.delete(key);
}
if (store.complete.has(elementId)) {
store.complete.set(elementId, true);
}
return true;
};
/**
* Check whether an optimized appear animation is active for an element.
*
* @param elementId Optimized appear id.
* @returns Whether any optimized appear animation is currently registered.
*
* @example
* ```ts
* if (hasOptimizedAppearAnimation('appear-1')) {
* console.log('Animation is active')
* }
* ```
*/
export const hasOptimizedAppearAnimation = (elementId) => {
if (!elementId || typeof window === 'undefined')
return false;
return window.MotionHasOptimisedAnimation?.(elementId) ?? false;
};
/**
* Mark Motion as mounted so late optimized-appear starters no-op.
*
* @returns Nothing.
*
* @example
* ```ts
* markMotionMounted()
* ```
*/
export const markMotionMounted = () => {
if (typeof window !== 'undefined') {
window.MotionIsMounted = true;
}
};
export { optimizedAppearDataAttribute };