Detect scroll direction in React js

27,598

Solution 1

This is because you defined a useEffect() without any dependencies, so your useEffect() will only run once, and it never calls handleNavigation() on y changes. To fix this you need to add y to your dependency array to tell your useEffect() run once the y value gets changes. Then you need another change to take effect in your code, where you are trying to initialize your y with window.scrollY, so you should either, do this in your useState() like:

const [y, setY] = useState(window.scrollY);

useEffect(() => {
  window.addEventListener("scroll", (e) => handleNavigation(e));

  return () => { // return a cleanup function to unregister our function since its gonna run multiple times
    window.removeEventListener("scroll", (e) => handleNavigation(e));
  };
}, [y]);

If for some reason window may not be available there or you don't want to do it here, you can do it in two separate useEffect()s.

So your useEffect()s should be like this:

useEffect(() => {
  setY(window.scrollY);
}, []);

useEffect(() => {
  window.addEventListener("scroll", (e) => handleNavigation(e));

  return () => { // return a cleanup function to unregister our function since its gonna run multiple times
    window.removeEventListener("scroll", (e) => handleNavigation(e));
  };
}, [y]);

UPDATE (Working solutions)

After implementing this solution on my own. I found out there are some notes that should be applied to this solution. So since the handleNavigation() will change y value directly we can ignore the y as our dependency and then add handleNavigation() as a dependency to our useEffect(), then due to this change we should optimize handleNavigation(), so we should use useCallback() for it. Then the final result will be something like this:

const [y, setY] = useState(window.scrollY);

const handleNavigation = useCallback(
  e => {
    const window = e.currentTarget;
    if (y > window.scrollY) {
      console.log("scrolling up");
    } else if (y < window.scrollY) {
      console.log("scrolling down");
    }
    setY(window.scrollY);
  }, [y]
);

useEffect(() => {
  setY(window.scrollY);
  window.addEventListener("scroll", handleNavigation);

  return () => {
    window.removeEventListener("scroll", handleNavigation);
  };
}, [handleNavigation]);

After a comment from @RezaSam I noticed that I made a teeny tiny mistake in the memoized version. Where I call handleNavigation within another arrow function, I found out (via the browser dev tool, event listeners tab) in each component rerender it will register a new event to the window so it might ruin the whole thing up.

Working demo:

CodeSandbox


Final Optimized Solution

After all, I ended up that memoization in this case will help us to register a single event, to recognize scroll direction but it is not fully optimized in printing the consoles, because we are consoling inside the handleNavigation function and there is no other way around to print the desired consoles in the current implementation.

So, I realized there is a better way of storing the last page scroll position each time we want to check for a new position. Also to get rid of a huge amount of consoling scrolling up and scrolling down, we should define a threshold (Use debounce approach) to trigger the scroll event change. So I just searched through the web a bit and ended up with this gist which was very useful. Then with the inspiration of it, I implement a simpler version.

This is how it looks:

const [scrollDir, setScrollDir] = useState("scrolling down");

useEffect(() => {
  const threshold = 0;
  let lastScrollY = window.pageYOffset;
  let ticking = false;

  const updateScrollDir = () => {
    const scrollY = window.pageYOffset;

    if (Math.abs(scrollY - lastScrollY) < threshold) {
      ticking = false;
      return;
    }
    setScrollDir(scrollY > lastScrollY ? "scrolling down" : "scrolling up");
    lastScrollY = scrollY > 0 ? scrollY : 0;
    ticking = false;
  };

  const onScroll = () => {
    if (!ticking) {
      window.requestAnimationFrame(updateScrollDir);
      ticking = true;
    }
  };

  window.addEventListener("scroll", onScroll);
  console.log(scrollDir);

  return () => window.removeEventListener("scroll", onScroll);
}, [scrollDir]);

How it works?

I will simply go from top to down and explain each block of code.

  • So I just defined a threshold point with the initial value of 0 then whenever the scroll goes up or down it will make the new calculation you can increase it if you don't want to immediately calculate new page offset.

  • Then instead of using scrollY I decide to use pageYOffset which is more reliable in cross browsing.

  • In the updateScrollDir function, we will simply check if the threshold is met or not, then if it met I will specify the scroll direction based on the current and previous page offset.

  • The most important part of it is the onScroll function. I just used requestAnimationFrame to make sure that we are calculating the new offset after the page got rendered completely after scroll. And then with ticking flag, we will make sure we are just run our event listener callback once in each requestAnimationFrame.

  • At last, we defined our listener and our cleanup function.

  • Then the scrollDir state will contain the actual scroll direction.

Working demo:

CodeSandbox

Solution 2

Try this in Next.js ( If you are struggling ) -

I have used this package - react-use-scroll-direction

import React from 'react'
import { useScrollDirection } from 'react-use-scroll-direction'

export const Window_Scroll_Direction = () => {
const [direction, setDirection] = React.useState(String)
const { isScrollingUp, isScrollingDown } = useScrollDirection()

React.useEffect(() => {
  isScrollingDown && setDirection('down')
  isScrollingUp && setDirection('up')
}, [isScrollingDown, isScrollingUp])

return (
  <>
    <div className="fixed top-0 bg-white">
      {direction === 'down' ? 'Scrolling down' : 'scrolling up'}
    </div>
 </>
 )
}

Solution 3

Just wanted to come in with a neat solution, it's quite similar to habbahans but looks a little neater in my opinion.

let oldScrollY = 0;

const [direction, setDirection] = useState('up');

const controlDirection = () => {
    if(window.scrollY > oldScrollY) {
        setDirection('down');
    } else {
        setDirection('up');
    }
    oldScrollY = window.scrollY;
}

useEffect(() => {
    window.addEventListener('scroll', controlDirection);
    return () => {
        window.removeEventListener('scroll', controlDirection);
    };
},[]);

Here you can just access the hidden state to do what you wish with in your code.

Solution 4

Most of the answers seems a bit over-engineered in my opinion.

Here's what I use in my nextjs projects:

function useVerticalScrollDirection() {
    const [direction, setDirection] = useState('up');

    let prevScrollY = 0;

    useEffect(() => {
        // Using lodash, we set a throttle to the scroll event
        // making it not fire more than once every 500 ms.
        window.onscroll = throttle(() => {

            // This value keeps the latest scrollY position
            const { scrollY } = window;

            // Checks if previous scrollY is less than latest scrollY
            // If true, we are scrolling downwards, else scrollig upwards
            const direction = prevScrollY < scrollY ? 'down' : 'up';

            // Updates the previous scroll variable AFTER the direction is set.
            // The order of events is key to making this work, as assigning
            // the previous scroll before checking the direction will result
            // in the direction always being 'up'.
            prevScrollY = scrollY;

            // Set the state to trigger re-rendering
            setDirection(direction);
        }, 500);

        return () => {
            // Remove scroll event on unmount
            window.onscroll = null;
        };
    }, []);

    return direction;
}

Then I use it my component like this:

function MyComponent() {
    const verticalScrollDirection = useVerticalScrollDirection();
    
    {....}
}

Solution 5

Here's my React hook solution, useScrollDirection:

import { useEffect, useState } from 'react'

export type ScrollDirection = '' | 'up' | 'down'

type HistoryItem = { y: number; t: number }

const historyLength = 32 // Ticks to keep in history.
const historyMaxAge = 512 // History data time-to-live (ms).
const thresholdPixels = 64 // Ignore moves smaller than this.

let lastEvent: Event
let frameRequested: Boolean = false
let history: HistoryItem[] = Array(historyLength)
let pivot: HistoryItem = { t: 0, y: 0 }

export function useScrollDirection({
  scrollingElement,
}: { scrollingElement?: HTMLElement | null } = {}): ScrollDirection {
  const [scrollDirection, setScrollDirection] = useState<ScrollDirection>('')

  useEffect(() => {
    const element: Element | null =
      scrollingElement !== undefined ? scrollingElement : document.scrollingElement
    if (!element) return

    const tick = () => {
      if (!lastEvent) return
      frameRequested = false

      let y = element.scrollTop
      const t = lastEvent.timeStamp
      const furthest = scrollDirection === 'down' ? Math.max : Math.min

      // Apply bounds to handle rubber banding
      const yMax = element.scrollHeight - element.clientHeight
      y = Math.max(0, y)
      y = Math.min(yMax, y)

      // Update history
      history.unshift({ t, y })
      history.pop()

      // Are we continuing in the same direction?
      if (y === furthest(pivot.y, y)) {
        // Update "high-water mark" for current direction
        pivot = { t, y }
        return
      }
      // else we have backed off high-water mark

      // Apply max age to find current reference point
      const cutoffTime = t - historyMaxAge
      if (cutoffTime > pivot.t) {
        pivot.y = y
        history.filter(Boolean).forEach(({ y, t }) => {
          if (t > cutoffTime) pivot.y = furthest(pivot.y, y)
        })
      }

      // Have we exceeded threshold?
      if (Math.abs(y - pivot.y) > thresholdPixels) {
        pivot = { t, y }
        setScrollDirection(scrollDirection === 'down' ? 'up' : 'down')
      }
    }

    const onScroll = (event: Event) => {
      lastEvent = event
      if (!frameRequested) {
        requestAnimationFrame(tick)
        frameRequested = true
      }
    }

    element.addEventListener('scroll', onScroll)
    return () => element.removeEventListener('scroll', onScroll)
  }, [scrollDirection, scrollingElement])

  return scrollDirection
}

Usage:

const [scrollingElement, setScrollingElement] = useState<HTMLElement | null>(null)
const ref = useCallback(node => setScrollingElement(node), [setScrollingElement])
const scrollDirection = useScrollDirection({ scrollingElement })

<ScrollingContainer {...{ ref }}>
  <Header {...{ scrollDirection }}>
</ScrollingContainer>

Based on https://github.com/pwfisher/scroll-intent and https://github.com/dollarshaveclub/scrolldir. Also ported to React here: https://github.com/AnakinYuen/scroll-direction.

Share:
27,598
Mugg84
Author by

Mugg84

Updated on March 09, 2022

Comments

  • Mugg84
    Mugg84 about 2 years

    I'm trying to detect if the scroll event is up or down but I can't find the solution.

    import React, { useState, useEffect } from "react";
    import { Link } from "react-router-dom";
    
    const Navbar = ({ className }) => {
      const [y, setY] = useState(0);
    
      const handleNavigation = (e) => {
        const window = e.currentTarget;
        if (y > window.scrollY) {
          console.log("scrolling up");
        } else if (y < window.scrollY) {
          console.log("scrolling down");
        }
        setY(window.scrollY);
      };
    
      useEffect(() => {
        setY(window.scrollY);
    
        window.addEventListener("scroll", (e) => handleNavigation(e));
      }, []);
    
      return (
        <nav className={className}>
          <p>
            <i className="fas fa-pizza-slice"></i>Food finder
          </p>
          <ul>
            <li>
              <Link to="/">Home</Link>
            </li>
            <li>
              <Link to="/about">About</Link>
            </li>
          </ul>
        </nav>
      );
    };
    
    export default Navbar;
    

    Basically it's always detected as "down" because y in handleNavigation is always 0. If i check the state in DevTool the y state updates but in the handleNavigation doesn't.

    Any suggestions what am I doing wrong?

    Thanks for your help

  • Admin
    Admin over 3 years
    Wow man! this is just amazing! Thank you so much for your elaborative work!
  • Reza Sam
    Reza Sam over 3 years
    handleNavigation is passed as an anonymous function so removeListner won't work and countless listeners are added to window
  • SMAKSS
    SMAKSS over 3 years
    @RezaSam Thanks for the note. I fixed it up and mentioned you for the honour.😉 BTW, the very last solution is neat and tidy and it is totally better because we are ticking the onScroll function on each browser paint.
  • Quv
    Quv about 3 years
    It doesn't work to me. deltaY is undefined in Desktop Chrome.
  • em_code
    em_code over 2 years
    WheelData doesn't exist anymore instead they have introduced new properties. Please check out this answer for more info: stackoverflow.com/a/54444535/12123712