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));
}, []);
...
}
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]);
...
}
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));
}, []);
...
}
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


