From 19d63308320e8f574fb35ae8b63b8f493fac3ec3 Mon Sep 17 00:00:00 2001 From: bfintal Date: Fri, 10 Jul 2026 20:03:30 +0800 Subject: [PATCH 1/2] fix: reduce scroll animation jank on Android Android Chrome batches/defers IntersectionObserver callbacks during momentum scrolling and blocks scroll on non-passive listeners, causing scroll-triggered animations to appear choppy and load late compared to iOS Safari. - enterViewport: add a bottom rootMargin lead time so reveals fire before the throttled callbacks lag, and guard against a zero/not-yet laid-out element height that could make the threshold NaN and throw. - pageScrolling/elementScrolling: use passive scroll listeners and coalesce work into a single requestAnimationFrame per frame instead of running anime.js on every scroll event. Co-authored-by: Cursor --- .../frontend/elementScrolling.js | 29 +++++++++++++++---- .../frontend/enterViewport.js | 23 ++++++++++++--- .../frontend/pageScrolling.js | 29 +++++++++++++++---- 3 files changed, 67 insertions(+), 14 deletions(-) diff --git a/src/interaction-types/frontend/elementScrolling.js b/src/interaction-types/frontend/elementScrolling.js index f2a21a2..bcaa4f0 100644 --- a/src/interaction-types/frontend/elementScrolling.js +++ b/src/interaction-types/frontend/elementScrolling.js @@ -9,7 +9,7 @@ InteractRunner.addInteractionConfig( { const animation = interaction.createTimelineInstance( 0, {} ) const trigger = interaction.getCurrentTrigger() - const scrollHandler = isFirstCall => { + const update = isFirstCall => { const rect = trigger.getBoundingClientRect() const viewportHeight = window.innerHeight @@ -20,19 +20,38 @@ InteractRunner.addInteractionConfig( { // Negative offset means start counting later and finish sooner, contracting the bounds. const elementScroll = viewportHeight - rect.top + offset const totalScroll = viewportHeight + rect.height + ( offset * 2 ) - const scrolled = elementScroll / totalScroll + const scrolled = totalScroll ? elementScroll / totalScroll : 0 // Clamp between 0 and 1 const clampedScrolled = Math.max( 0, Math.min( 1, scrolled ) ) animation.seekPercentage( clampedScrolled, isFirstCall === true ? 0 : smoothness ) } - window.addEventListener( 'scroll', scrollHandler ) - scrollHandler( true ) + // Coalesce scroll events into a single rAF so we only seek once per + // frame. Firing anime.js on every scroll event janks slower Android + // devices. + let rafId = null + const scrollHandler = () => { + if ( rafId !== null ) { + return + } + rafId = window.requestAnimationFrame( () => { + rafId = null + update( false ) + } ) + } + + // Passive so the listener never blocks scrolling on mobile. + window.addEventListener( 'scroll', scrollHandler, { passive: true } ) + update( true ) return () => { + if ( rafId !== null ) { + window.cancelAnimationFrame( rafId ) + rafId = null + } animation.destroy() - window.removeEventListener( 'scroll', scrollHandler ) + window.removeEventListener( 'scroll', scrollHandler, { passive: true } ) } }, }, diff --git a/src/interaction-types/frontend/enterViewport.js b/src/interaction-types/frontend/enterViewport.js index 7113e0c..3ce1ede 100644 --- a/src/interaction-types/frontend/enterViewport.js +++ b/src/interaction-types/frontend/enterViewport.js @@ -10,10 +10,18 @@ InteractRunner.addInteractionConfig( { // Normalize the threshold to be between 0 and the maximum possible threshold // for the current trigger. This ensures the interaction always works. const rect = trigger.getBoundingClientRect() - const visibleHeight = Math.min( window.innerHeight, rect.height ) - const maxThreshold = ( visibleHeight / rect.height ) - 0.01 const threshold = parseFloat( interaction.getOption( 'threshold', 0.3 ) ) - const normalizedThreshold = Math.min( Math.max( threshold, 0 ), maxThreshold ) + // Guard against a zero (or not-yet-laid-out) height. On slower + // devices the element may not have its final height when we + // initialize, which would make maxThreshold NaN and throw when + // constructing the IntersectionObserver. + let normalizedThreshold = Math.max( threshold, 0 ) + if ( rect.height > 0 ) { + const visibleHeight = Math.min( window.innerHeight, rect.height ) + const maxThreshold = ( visibleHeight / rect.height ) - 0.01 + normalizedThreshold = Math.min( normalizedThreshold, maxThreshold ) + } + normalizedThreshold = Math.max( normalizedThreshold, 0 ) // Use Intersection Observer to detect when the target enters the viewport const callback = entries => { @@ -30,7 +38,14 @@ InteractRunner.addInteractionConfig( { } ) } - const io = new IntersectionObserver( callback, { threshold: normalizedThreshold } ) // eslint-disable-line compat/compat + // Fire slightly before the element scrolls into view. Android + // Chrome batches/defers IntersectionObserver callbacks during + // momentum (fling) scrolling, so without this lead time reveals + // "pop in" late compared to iOS Safari. + const io = new IntersectionObserver( callback, { // eslint-disable-line compat/compat + threshold: normalizedThreshold, + rootMargin: '0px 0px 15% 0px', + } ) io.observe( trigger ) return () => { diff --git a/src/interaction-types/frontend/pageScrolling.js b/src/interaction-types/frontend/pageScrolling.js index beb44a5..5c2cfc3 100644 --- a/src/interaction-types/frontend/pageScrolling.js +++ b/src/interaction-types/frontend/pageScrolling.js @@ -7,19 +7,38 @@ InteractRunner.addInteractionConfig( { const smoothness = interaction.getOption( 'smoothness', 200 ) const animation = interaction.createTimelineInstance( 0, {} ) - const scrollHandler = isFirstCall => { + const update = isFirstCall => { const winScroll = document.body.scrollTop || document.documentElement.scrollTop const height = document.documentElement.scrollHeight - document.documentElement.clientHeight - const scrolled = winScroll / height + const scrolled = height ? winScroll / height : 0 animation.seekPercentage( scrolled, isFirstCall === true ? 0 : smoothness ) } - window.addEventListener( 'scroll', scrollHandler ) - scrollHandler( true ) + // Coalesce scroll events into a single rAF so we only seek once per + // frame. Firing anime.js on every scroll event janks slower Android + // devices. + let rafId = null + const scrollHandler = () => { + if ( rafId !== null ) { + return + } + rafId = window.requestAnimationFrame( () => { + rafId = null + update( false ) + } ) + } + + // Passive so the listener never blocks scrolling on mobile. + window.addEventListener( 'scroll', scrollHandler, { passive: true } ) + update( true ) return () => { + if ( rafId !== null ) { + window.cancelAnimationFrame( rafId ) + rafId = null + } animation.destroy() - window.removeEventListener( 'scroll', scrollHandler ) + window.removeEventListener( 'scroll', scrollHandler, { passive: true } ) } }, }, From a457aba35af2e243fa73c1758efdfb861db58e9e Mon Sep 17 00:00:00 2001 From: bfintal Date: Fri, 10 Jul 2026 20:13:07 +0800 Subject: [PATCH 2/2] fix: clamp page scroll progress and limit rootMargin to Android Clamp page-scrolling progress to [0, 1] to match elementScrolling and avoid invalid seeks during iOS overscroll. Apply the enter-viewport rootMargin lead time only on Android so desktop and iOS keep the previous intersection behavior. Co-authored-by: Cursor --- src/interaction-types/frontend/enterViewport.js | 15 +++++++++------ src/interaction-types/frontend/pageScrolling.js | 5 ++++- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/interaction-types/frontend/enterViewport.js b/src/interaction-types/frontend/enterViewport.js index 3ce1ede..31c8125 100644 --- a/src/interaction-types/frontend/enterViewport.js +++ b/src/interaction-types/frontend/enterViewport.js @@ -38,14 +38,17 @@ InteractRunner.addInteractionConfig( { } ) } - // Fire slightly before the element scrolls into view. Android - // Chrome batches/defers IntersectionObserver callbacks during + // Fire slightly before the element scrolls into view on Android only. + // Android Chrome batches/defers IntersectionObserver callbacks during // momentum (fling) scrolling, so without this lead time reveals // "pop in" late compared to iOS Safari. - const io = new IntersectionObserver( callback, { // eslint-disable-line compat/compat - threshold: normalizedThreshold, - rootMargin: '0px 0px 15% 0px', - } ) + const isAndroid = /Android/i.test( navigator.userAgent ) + const observerOptions = { threshold: normalizedThreshold } + if ( isAndroid ) { + observerOptions.rootMargin = '0px 0px 15% 0px' + } + + const io = new IntersectionObserver( callback, observerOptions ) // eslint-disable-line compat/compat io.observe( trigger ) return () => { diff --git a/src/interaction-types/frontend/pageScrolling.js b/src/interaction-types/frontend/pageScrolling.js index 5c2cfc3..33cd7b3 100644 --- a/src/interaction-types/frontend/pageScrolling.js +++ b/src/interaction-types/frontend/pageScrolling.js @@ -11,7 +11,10 @@ InteractRunner.addInteractionConfig( { const winScroll = document.body.scrollTop || document.documentElement.scrollTop const height = document.documentElement.scrollHeight - document.documentElement.clientHeight const scrolled = height ? winScroll / height : 0 - animation.seekPercentage( scrolled, isFirstCall === true ? 0 : smoothness ) + + // Clamp between 0 and 1 (iOS overscroll can produce negative values). + const clampedScrolled = Math.max( 0, Math.min( 1, scrolled ) ) + animation.seekPercentage( clampedScrolled, isFirstCall === true ? 0 : smoothness ) } // Coalesce scroll events into a single rAF so we only seek once per