diff --git a/src/routes/(marketing)/(components)/bento/(animations)/auth.svelte b/src/routes/(marketing)/(components)/bento/(animations)/auth.svelte index 1b3127a30f3..8133e6cfe83 100644 --- a/src/routes/(marketing)/(components)/bento/(animations)/auth.svelte +++ b/src/routes/(marketing)/(components)/bento/(animations)/auth.svelte @@ -16,7 +16,15 @@ let currentAnimation: WriteAnimation | null = null; $effect(() => { - inView( + // The write animation can settle after the component has been destroyed + // (e.g. the user clicks the card mid-animation), by which point + // `bind:this` has already reset `button` to null. + const pulseButton = () => { + if (!button) return; + animate(button, { scale: [1, 0.95, 1] }, { duration: 0.25 }); + }; + + const stopInView = inView( container, () => { if (!isMobile()) return; @@ -28,9 +36,7 @@ 1000, password.length ); - currentAnimation.then(() => { - animate(button, { scale: [1, 0.95, 1] }, { duration: 0.25 }); - }); + currentAnimation.then(pulseButton); return () => { currentAnimation?.cancel(); currentAnimation = unwrite( @@ -43,14 +49,12 @@ { amount: 'all' } ); - hover(container, () => { + const stopHover = hover(container, () => { if (isMobile()) return; currentAnimation?.cancel(); currentAnimation = write('•••••••••••••', (v) => (password = v), 1000, password.length); - currentAnimation.then(() => { - animate(button, { scale: [1, 0.95, 1] }, { duration: 0.25 }); - }); + currentAnimation.then(pulseButton); return () => { currentAnimation?.cancel(); currentAnimation = unwrite( @@ -60,6 +64,13 @@ ); }; }); + + return () => { + stopInView(); + stopHover(); + currentAnimation?.cancel(); + currentAnimation = null; + }; }); diff --git a/src/routes/(marketing)/(components)/bento/(animations)/databases.svelte b/src/routes/(marketing)/(components)/bento/(animations)/databases.svelte index 526776a59fe..fd8ecfbb11f 100644 --- a/src/routes/(marketing)/(components)/bento/(animations)/databases.svelte +++ b/src/routes/(marketing)/(components)/bento/(animations)/databases.svelte @@ -64,10 +64,16 @@ let table: HTMLElement; let shouldAnimate = $state(false); + // Motion's cleanups detach the pointer listener and the observer, but an animation + // they already started keeps running against the card after it leaves the DOM. Only + // one can be in flight per element — a new one supersedes the last — so holding the + // latest controls is enough to stop the work on teardown. + let animation: ReturnType | undefined; + $effect(() => { - hover(container, () => { + const stopHover = hover(container, () => { if (isMobile()) return; - animate( + animation = animate( table, { x: [12, -5], @@ -82,7 +88,7 @@ shouldAnimate = true; return () => { - animate( + animation = animate( table, { x: [-5, 12], @@ -99,12 +105,12 @@ }; }); - inView( + const stopInView = inView( container, () => { if (!isMobile()) return; - animate( + animation = animate( table, { x: [12, -5], @@ -119,7 +125,7 @@ shouldAnimate = true; return () => { - animate( + animation = animate( table, { x: [-5, 12], @@ -137,6 +143,12 @@ }, { amount: 'all' } ); + + return () => { + stopHover(); + stopInView(); + animation?.stop(); + }; }); diff --git a/src/routes/(marketing)/(components)/bento/(animations)/functions.svelte b/src/routes/(marketing)/(components)/bento/(animations)/functions.svelte index f20f91630ab..13f61447bce 100644 --- a/src/routes/(marketing)/(components)/bento/(animations)/functions.svelte +++ b/src/routes/(marketing)/(components)/bento/(animations)/functions.svelte @@ -25,7 +25,7 @@ $effect(() => { baseWidth = activeCommand.offsetWidth; - hover(container, () => { + const stopHover = hover(container, () => { if (isMobile()) return; widthAnim?.stop(); @@ -46,7 +46,7 @@ }; }); - inView( + const stopInView = inView( container, () => { if (!isMobile()) return; @@ -70,6 +70,13 @@ }, { amount: 'all' } ); + + return () => { + stopHover(); + stopInView(); + widthAnim?.stop(); + widthAnim = null; + }; }); diff --git a/src/routes/(marketing)/(components)/bento/(animations)/messaging.svelte b/src/routes/(marketing)/(components)/bento/(animations)/messaging.svelte index 5871c056ab7..6defa0fe8e9 100644 --- a/src/routes/(marketing)/(components)/bento/(animations)/messaging.svelte +++ b/src/routes/(marketing)/(components)/bento/(animations)/messaging.svelte @@ -15,6 +15,12 @@ let device: HTMLElement; let notification: HTMLElement; + // Motion's cleanups detach the pointer listener and the observer, but a sequence they + // already started keeps running against the card after it leaves the DOM. Only one + // can be in flight here — a new one supersedes the last — so holding the latest + // controls is enough to stop the work on teardown. + let animation: ReturnType | undefined; + onMount(() => { const from: AnimationSequence = [ [notification, { opacity: 0, y: -30, filter: 'blur(4px)' }, { duration: 0.2 }], @@ -26,27 +32,33 @@ [notification, { opacity: 1, y: 0, filter: 'blur(0px)' }, { duration: 0.2, at: 0.15 }] ]; - inView( + const stopInView = inView( container, () => { if (!isMobile()) return; - animate(to); + animation = animate(to); return () => { - animate(from); + animation = animate(from); }; }, { amount: 'all' } ); - hover(container, () => { + const stopHover = hover(container, () => { if (isMobile()) return; - animate(to); + animation = animate(to); return () => { - animate(from); + animation = animate(from); }; }); + + return () => { + stopInView(); + stopHover(); + animation?.stop(); + }; }); diff --git a/src/routes/(marketing)/(components)/bento/(animations)/realtime.svelte b/src/routes/(marketing)/(components)/bento/(animations)/realtime.svelte index 462c26a68ca..fc8a059b6f2 100644 --- a/src/routes/(marketing)/(components)/bento/(animations)/realtime.svelte +++ b/src/routes/(marketing)/(components)/bento/(animations)/realtime.svelte @@ -13,6 +13,12 @@ let topRightCursor: HTMLElement; let topRightPiece: HTMLElement; + // Motion's cleanups detach the pointer listener and the observer, but a sequence they + // already started keeps running against the card after it leaves the DOM. Only one + // can be in flight here — a new one supersedes the last — so holding the latest + // controls is enough to stop the work on teardown. + let animation: ReturnType | undefined; + onMount(() => { const from: AnimationSequence = [ [ @@ -34,27 +40,33 @@ [topRightCursor, { scale: 1 }, { duration: 0.25, at: 0.35 }] ]; - inView( + const stopInView = inView( container, () => { if (!isMobile()) return; - animate(to); + animation = animate(to); return () => { - animate(from); + animation = animate(from); }; }, { amount: 'all' } ); - hover(container, () => { + const stopHover = hover(container, () => { if (isMobile()) return; - animate(to); + animation = animate(to); return () => { - animate(from); + animation = animate(from); }; }); + + return () => { + stopInView(); + stopHover(); + animation?.stop(); + }; }); diff --git a/src/routes/(marketing)/(components)/bento/(animations)/sites.svelte b/src/routes/(marketing)/(components)/bento/(animations)/sites.svelte index 4edae6cff39..964f17e89ec 100644 --- a/src/routes/(marketing)/(components)/bento/(animations)/sites.svelte +++ b/src/routes/(marketing)/(components)/bento/(animations)/sites.svelte @@ -46,9 +46,10 @@ ]; let shouldAnimate = $state(false); + let secondsAnimation: ReturnType | null = null; $effect(() => { - hover(container, () => { + const stopHover = hover(container, () => { if (isMobile()) return; shouldAnimate = true; @@ -57,6 +58,7 @@ onUpdate: (latest) => (seconds = +latest.toFixed()), duration: 44 }); + secondsAnimation = animation; currentAnimation?.cancel(); currentAnimation = write( @@ -82,7 +84,7 @@ }; }); - inView( + const stopInView = inView( container, () => { if (!isMobile()) return; @@ -92,6 +94,7 @@ onUpdate: (latest) => (seconds = +latest.toFixed()), duration: 44 }); + secondsAnimation = animation; currentAnimation?.cancel(); currentAnimation = write( @@ -118,6 +121,15 @@ }, { amount: 'all' } ); + + return () => { + stopHover(); + stopInView(); + secondsAnimation?.stop(); + secondsAnimation = null; + currentAnimation?.cancel(); + currentAnimation = null; + }; }); diff --git a/src/routes/(marketing)/(components)/bento/(animations)/storage.svelte b/src/routes/(marketing)/(components)/bento/(animations)/storage.svelte index 1129572444d..c4f6b07bfbd 100644 --- a/src/routes/(marketing)/(components)/bento/(animations)/storage.svelte +++ b/src/routes/(marketing)/(components)/bento/(animations)/storage.svelte @@ -9,14 +9,24 @@ let container: HTMLElement; let image: HTMLElement; + // Motion's cleanups detach the pointer listener and the observer, but an animation + // they already started keeps running against the card after it leaves the DOM. Only + // one can be in flight per element — a new one supersedes the last — so holding the + // latest controls is enough to stop the work on teardown. + let animation: ReturnType | undefined; + $effect(() => { - hover(container, () => { + const stopHover = hover(container, () => { if (isMobile()) return; - animate(image, { borderRadius: '24px', filter: 'grayscale(25%)' }, { duration: 0.2 }); + animation = animate( + image, + { borderRadius: '24px', filter: 'grayscale(25%)' }, + { duration: 0.2 } + ); return () => { - animate( + animation = animate( image, { borderRadius: '4px', @@ -27,12 +37,12 @@ }; }); - inView( + const stopInView = inView( container, () => { if (!isMobile()) return; - animate( + animation = animate( image, { borderRadius: '24px', @@ -42,7 +52,7 @@ ); return () => { - animate( + animation = animate( image, { borderRadius: '4px', filter: 'grayscale(100%)' }, { duration: 0.2 } @@ -53,6 +63,12 @@ amount: 'all' } ); + + return () => { + stopHover(); + stopInView(); + animation?.stop(); + }; });