UNPKG

@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
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 };