Search code examples
javascriptreactjssettimeoutgsap

how to know whether we are scrolling the page or not?


How to detect mouse scroll event, how to get a state where we know that we're not currently doing the scrolling. How to create react custom hook out of that situation. the hook is going to produce false state if there's no scroll event going on, and true state for the opposite situation.

I've used setTimeout for this problem before but the result was not quiet what I want. the code looks like this when using setTimeout

  const [isWheeling, setIsWheeling] = useState(false);

  useEffect(() => {
    let scrollEndTimer;
    const wheelTimer = () => {
      setIsWheeling(true)

      clearTimeout(scrollEndTimer)
      scrollEndTimer = setTimeout(() => {
      // setTimeout(() => {
        setIsWheeling(false)
      }, 300)
    }

    window.addEventListener('wheel', wheelTimer)
    return () => window.removeEventListener('wheel', wheelTimer)
  }, [])

  useEffect(() => {
    if (isWheeling) {
      gsap.to(svgHeightRef.current, {
        value: 200,
        duration: 0.5 
      })
    } else {
      gsap.to(svgHeightRef.current, {
        value: 500,
        duration: 1
      })
    }

  }, [isWheeling])

it does not behave the way I want it to, there might be something wrong with the way I implement the setTimeout.

The issue is simply how to detect whether I move my mouse wheel or not. If I do nothing with the mouse wheel, give me false state, and whenever I touch my wheel, it gives me true state right away.

Can anyone help me regarding of this problem. Thank you


Solution

  • import * as React from 'react'
    
    export const useIsScrolling = (target = document) => {
      const [isScrolling, setIsScrolling] = React.useState(false)
      const on = React.useCallback(() => setIsScrolling(true), [])
      const off = React.useCallback(() => setIsScrolling(false), [])
      React.useEffect(() => {
        target.addEventListener('scroll', on, { passive: true })
        target.addEventListener('scrollend', off)
        return () => {
          target?.removeEventListener('scroll', on)
          target?.removeEventListener('scrollend', off)
        }
      }, [])
    
      return isScrolling
    }
    

    Use as

    // document scrolling:
    const isScrolling = useIsSCrolling()
    
    // or, with a specific target element:
    const isScrolling = useIsScrolling(
      document.querySelector('your-selector-here')
    )
    

    Demo:

    const useIsScrolling = (target = document) => {
      const [isScrolling, setIsScrolling] = React.useState(false)
      const on = React.useCallback(() => setIsScrolling(true), [])
      const off = React.useCallback(() => setIsScrolling(false), [])
      React.useEffect(() => {
        target.addEventListener('scroll', on, { passive: true })
        target.addEventListener('scrollend', off)
        return () => {
          target && target.removeEventListener('scroll', on)
          target && target.removeEventListener('scrollend', off)
        }
      }, [])
    
      return isScrolling
    }
    
    const App = () => {
      const isScrolling = useIsScrolling()
      return <pre>{JSON.stringify({ isScrolling }, null, 2)}</pre>
    }
    ReactDOM.createRoot(root).render(<App />)
    #root {
      height: 800vh;
      background: repeating-linear-gradient(
        45deg,
        #fff,
        #fff 30px,
        #f5f5f5 30px,
        #f5f5f5 60px
      );
    }
    pre {
      position: fixed;
      top: 0;
    }
    <script src="https://unpkg.com/react@18/umd/react.development.js" crossorigin></script>
    <script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" crossorigin></script>
    <div id="root"></div>

    Notes:

    • Safari and some mobile browsers do not support scrollend event on either document or element.
      If you need this to work in those browsers, you'll need to use a polyfill - there might be others, but this one comes up first, so I guess it's most used.
    • the optional chaining on removing the events is needed for cases where the initial target is no longer present in DOM when your component is unmounted.
    • if using Next, this will only work as expected on client side components
    • in general, running code on scroll listeners can cause performance issues. It is not the case here, as I used a passive scroll listener.
    • scroll event does not bubble 1. It is important you get the target right for this to work (e.g: if the scrolling happens in some app container and you bind on document or window it won't work as expected).
    • the above code covers all cases of scrolling, not only wheel based scrolling (keyboard based, touch based, other pointer scrolling methods and programmatic scrolling), with two conditions:
      • the target is scrollable and actually scrolled (otherwise scroll event never fires, even if the pointer/keyboard/touch event is performed)
      • .preventDefault() is not called on the triggering event (e.g: wheel, touchstart, etc)

    1 - with one exception: when the user scrolls the document, scroll and scrollend are triggered on both document and window.