Detect scroll direction in React js
Asked Answered
S

11

35

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 the handleNavigation doesn't.

Do you have any suggestions on what I am doing wrong?

Thanks for your help

Sheepdip answered 21/6, 2020 at 10:10 Comment(2)
If anyone wants to check out the implementation details of a custom React hooks which detects the scroll direction: robinwieruch.de/react-hook-scroll-directionKlee
@RobinWieruch your hook is very performant compared to mine.Stationery
K
79

TLDR;

Since this answer has drawn some attention, I've just developed an npm package based on it to allow everyone to use it as an independent package/library in their projects. I also wrote a story on the Hackernoon and went through the details about this answer (This is published in more than 10 languages, so it may be easier for non-English readers to read through the details).

If you want something to work with right away in React itself or serverside rendering frameworks/libraries like Nextjs, Remixjs, Gatsbyjs, etc., you can add it to your project as a dependency:

Demo

Edit react-scroll-direction

npm i @smakss/react-scroll-direction
or
yarn add @smakss/react-scroll-direction

Read more here.

The answer and its description

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 must add y to your dependency array to tell your useEffect() to run whenever the y value changes. Then it would be best if you had another change to take effect in your code, where you are trying to initialize your y with window.scrollY, so you should 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 it will run multiple times
    window.removeEventListener("scroll", (e) => handleNavigation(e));
  };
}, [y]);

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

So your useEffect() 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 it will run multiple times
    window.removeEventListener("scroll", (e) => handleNavigation(e));
  };
}, [y]);

UPDATE (Working Solutions)

After implementing this solution on my own, I found out some notes 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 a 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) that each component will register a new event to the window, so it might ruin the whole thing.

Working demo:

CodeSandbox


Final Optimised Solution

After all, I concluded that memoization, in this case, will help us register a single event to recognize the scroll direction. Still, it is not fully optimized for printing the consoles because we are consoling inside the handleNavigation function. There is no way to print the desired consoles in the current implementation.

So, I realized there is a better way to store the last page scroll position each time we want to check a new status. Also, to eliminate a vast 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 the web a bit and ended up with this gist, which was very useful. Then, with the inspiration of it, I implemented 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 go from top to bottom and explain each code block.

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

  • Then, instead of scrollY, I decided to use pageYOffset, which is more reliable in cross-browsing.

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

  • The most crucial part of it is the onScroll function. I just used requestAnimationFrame to ensure that we calculate the new offset after the page is rendered wholly after scrolling. And then, with the ticking flag, we will ensure we are just running our event listener callback once in each requestAnimationFrame.

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

The scrollDir state will then contain the actual scroll direction.

Working demo:

CodeSandbox

Kraemer answered 21/6, 2020 at 10:28 Comment(3)
Wow man! this is just amazing! Thank you so much for your elaborative work!Raneeraney
handleNavigation is passed as an anonymous function so removeListner won't work and countless listeners are added to windowElrod
tried not working on table.Decanter
J
5

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.

Joggle answered 11/9, 2021 at 6:1 Comment(0)
C
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();
    
    {....}
}

Ceremonious answered 29/6, 2021 at 21:29 Comment(1)
Thanks! Just one small change I would prefer is having const prevScrollY = React.useRef(0) because just with let prevScrollY the stored value may be lost between re-renders.Blubberhead
L
3

Try this package - react-use-scroll-direction

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

export const MyComponent = () => {
  const { isScrollingDown } = useScrollDirection()

  return (
    <div>
      {isScrollingDown ? 'Scrolling down' : 'scrolling up'}
    </div>
  )
}
Lexicologist answered 7/7, 2021 at 21:24 Comment(1)
react-scroll-direction is the npm package developed by the person who write the accepted answer to this questionMicrowave
W
2

I was looking around and couldn't find a simple solution, so I looked into the event itself and there exists a "deltaY" which makes everything way simpler (no need to keep state of the last scroll value). The "deltaY" value shows the change in "y" that the event had (a positive deltaY means it was a scroll down event, and a negative deltaY means it was a scroll up event).

Here's how it works:

componentDidMount() {
    window.addEventListener('scroll', e => this.handleNavigation(e));
}

handleNavigation = (e) => {
    if (e.deltaY > 0) {
        console.log("scrolling down");
    } else if (e.deltaY < 0) {
        console.log("scrolling up");
    }
};
Wapiti answered 1/1, 2021 at 23:39 Comment(1)
It doesn't work to me. deltaY is undefined in Desktop Chrome.Maypole
M
1

I found this neat & simple solution just few lines of codes


<div onWheel={ event => {
   if (event.nativeEvent.wheelDelta > 0) {
     console.log('scroll up');
   } else {
     console.log('scroll down');
   }
 }}
>
  scroll on me!
</div>

onWheel synthetic event returns an event object having an attribute named nativeEvent containing the original event information. wheelDelta is used to detect the direction even if there is no effective scroll (overflow:hidden).

This is original source -> http://blog.jonathanargentiero.com/detect-scroll-direction-on-react/

Mielke answered 15/6, 2021 at 22:37 Comment(1)
WheelData doesn't exist anymore instead they have introduced new properties. Please check out this answer for more info: https://mcmap.net/q/428663/-how-to-fix-quot-property-39-wheeldelta-39-does-not-exist-on-type-39-wheelevent-39-quot-while-upgrading-to-angular-7-rxjs6Ryals
F
1

I solved using onWheel react prop (wheel event) and deltaY event property:

<div onWheel={(e)=>console.log(e.deltaY > 0 ? 'down' : 'up')}>...</div>

It works on Chrome 123 and Firefox 115

Faus answered 5/4 at 10:54 Comment(0)
B
0

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.

Brit answered 13/4, 2021 at 7:38 Comment(0)
F
0

Here is my solution that extends some of the ideas found here. It fires only once every direction change and adds some params to fine-tune the hook call

const useScrollDirection = ({
    ref,
    threshold,
    debounce,
    scrollHeightThreshold,
}) => {
    threshold = threshold || 10;
    debounce = debounce || 10;
    scrollHeightThreshold = scrollHeightThreshold || 0;
    const [scrollDir, setScrollDir] = useState(null);
    const debouncedSetScrollDir = _.debounce(setScrollDir, debounce);

    useEffect(() => {
        let lastScrollY = ref?.current?.scrollTop;
        let lastScrollDir;
        let ticking = false;
        const hasScrollHeightThreshold =
            ref?.current?.scrollHeight - ref?.current?.clientHeight >
            scrollHeightThreshold;

        const updateScrollDir = () => {
            const scrollY = ref?.current?.scrollTop;
            if (
                Math.abs(scrollY - lastScrollY) < threshold ||
                !hasScrollHeightThreshold
            ) {
                ticking = false;
                return;
            }
            const newScroll = scrollY > lastScrollY ? 'down' : 'up';
            if (newScroll !== lastScrollDir) {
                debouncedSetScrollDir(newScroll);
            }
            lastScrollY = scrollY > 0 ? scrollY : 0;
            lastScrollDir = newScroll;
            ticking = false;
        };

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

        ref?.current?.addEventListener('scroll', onScroll);

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

    return scrollDir;
};

Codepen demo

Fun answered 4/11, 2021 at 15:44 Comment(0)
B
0

I have been searches this things for an hours. But no one solution work for me, so i write like this and worked for my next.js project.

const [currentScroll, setCurrentScroll] = useState(0)
const [lastScroll, setLastScroll] = useState(0)
const [scrollUp, setScrollUp] = useState(false)

useEffect(()=>{
  function handleScroll(){
    setCurrentScroll(scrollY)

    // check if current scroll 
    // more than last scroll
    if(currentScroll>lastScroll){
      setScrollUp('Down')
    } else {
      setScrollUp('Up')
    }
  }

  // change the last scroll
  setLastScroll(scrollY)
  
  window.addEventListener('scroll', handleScroll)
  
  return () => {
   window.removeEventListener('scroll', handleScroll)
  }


// this needed to change last scroll
// if currentscroll has change
},[currentScroll]) }
Bhagavadgita answered 21/9, 2022 at 4:40 Comment(1)
maybe my answer inspired from @smakks caseBhagavadgita
N
0
useEffect(() => {
    setY(window.scrollY);

    window.addEventListener("scroll", (e) => handleNavigation(e));
  }, []);

Because you pass an empty array into useEffect, so this hook only runs once and does not render every time y changes.

To change the value of y, we need to re-render when scrolling, so we need to pass a state into this array. In here, we see that window.scrollY always changes once you scroll, so [window.scrollY] can be the best solution to resolve your problem.

useEffect(() => {
    setY(window.scrollY);

    window.addEventListener("scroll", (e) => handleNavigation(e));
  }, [window.scrollY]);

CopePen Demo

Your problem relates to using the dependency in useEffect, you can reference it in this link from the React documentation (note part): useEffect Dependency

Newton answered 13/1, 2023 at 2:8 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.