ReactHustle

How to Track Scroll Direction Inside IntersectionObserver in React

Jasser Mark Arioste

Jasser Mark Arioste

How to Track Scroll Direction Inside IntersectionObserver in React

This guide is about tracking the scroll direction inside an intersection observer that is initialized via useEffect. If you're in a similar situation, you can most likely get some ideas from this guide.

I recently modified this blog's TableOfContents component to highlight the item depending on where the user's scroll position. I used an IntersectionObserver API to check an h2 heading intersects with the top 15-20% of the screen/window.

Initializing the IntersectionObserver  #

If you want to use IntersectionObserver in React, you do it via useEffect. In my case, I used the following code to track if headings are intersecting with the observer.

const TableOfContents = () => {
  const headingsRef = useRef<Element[]>();
  const [active, setActive] = useState(""); //active heading
  useEffect(() => {
    const headings = Array.from(
      document.querySelectorAll("h2[id], h3[id], h4[id]")
    );
    headingsRef.current = headings;
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          const id = entry.target.getAttribute("id") ?? "";
          if (entry.isIntersecting) {
            setActive(id);
          }
        });
      },
      {
        rootMargin: "0% 0% -85% 0%",
        threshold: 0.2,
      }
    );
    headings.forEach((e) => observer.observe(e));
  }, []);
  ...
}
1234567891011121314151617181920212223242526

Line #3: a state to track the active ToC item.
Lines #13-15: if an <h2> heading intersects, set it as the current active ToC item
Line 19: Set the root margin to observe only the top 15% of the screen.

Understanding the behavior #

It was pretty straightforward to implement this. However, I wanted this component to have similar behavior to MDN's TableOfContents component. If the user scrolls up, and the heading is no longer at the top 20% threshold, it highlights the previous item.

The example below shows that the Description heading is highlighted. The red rectangle represents the top 20% of the page.

The description item is highlighted because it's heading inside the box

If the user scrolls up just a bit, the current highlighted item changes. Now "Syntax" is highlighted even though it hasn't reached the syntax heading.

The previous heading is highlighted.

This is the key behavior I wanted and the code above is currently lacking. We need to track the scroll event!

Most of the guides that track scroll direction is using a custom hook that tracks the direction via useState. However, this doesn't work because if we use that dependency inside the useEffect hook, it will reinitialize the IntersectionObserver.

For example:

import useScrollDirection from "./hooks/useScrollDirection"
const TableOfContents = () => {
  ...
  const scollDir = useScrollDirection();
  useEffect(() => {
    //any change in dir will rerun code inside here.
  }, [scollDir]);
  ...
}
123456789

The Solution #

How do we track something without re-rendering? There's only one way to do it in react. And it's through the useRef hook. In this case, we don't have to implement a custom hook. Let's modify our code.

const TableOfContents = () => {
  const headingsRef = useRef<Element[]>();
  const scrollRef = useRef(0);
  const [active, setActive] = useState(""); //active
  useEffect(() => {
    const headings = Array.from(
      document.querySelectorAll("h2[id], h3[id], h4[id]")
    );
    const ids = headings.map((e) => e.id);
    headingsRef.current = headings;
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          const id = entry.target.getAttribute("id") ?? "";

          if (entry.isIntersecting) {
            setActive(id);
            scrollRef.current = window.scrollY;
            return;
          }
          const diff = scrollRef.current - window.scrollY;
          const isScrollingUp = diff > 0;
          const currentIndex = ids.indexOf(id);
          const prevEntry = ids[currentIndex - 1];
          if (isScrollingUp) {
            const id = prevEntry;
            setActive(id);
          }
        });
      },
      {
        rootMargin: "0% 0% -85% 0%",
        threshold: 0.2,
      }
    );
    headings.forEach((e) => observer.observe(e));
  }, []);
  ...
}
123456789101112131415161718192021222324252627282930313233343536373839

Line #3: We initialize a MutableRefObject through useRef to store the current window.scrollY position.
Lines #17-18:  If the heading intersects with the observer, we store the current window.scrollY position.
Lines #21-22: We check the scroll direction. if the previous position is greater than the current scrollY position, it means that the user is scrolling up.
Lines #23-27: Set the active item to the previous one if possible.

That's It!

We don't even have to use window.addEventListener('scroll' , handler) since we have an IntersectionObserver that does the job!

Conclusion #

We were able to track the scroll direction inside an IntersectionObserver in React without rerendering the component. I can't believe the solution is so simple! 

If you like this tutorial, please leave a like or share this article. For future tutorials like this, please subscribe to our newsletter or follow me on Twitter.

Demo and Code #

Resources #

Credits #

Image by Sofia Cristina Córdova Valladares from Pixabay 

Share this post!

Related Posts