How to Track Scroll Direction Inside IntersectionObserver in React
Jasser Mark Arioste
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.
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.
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 #
Here's the link to the demo: https://react-table-of-contents.vercel.app/
Full Code: https://github.com/jmarioste/react-table-of-contents
Resources #
IntersectionObserver - https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver
IntersectionObserver.rootMargin - https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/rootMargin
useScrollDirection Guide using useState- https://www.fabrizioduroni.it/2022/01/02/react-detect-scroll-direction/
useScrollDirection using useState - https://www.robinwieruch.de/react-hook-scroll-direction/
Credits #
Image by Sofia Cristina Córdova Valladares from Pixabay