Highlight active menu item with scrollspy hook ⚛
Rafał Goławski
Posted on November 7, 2021
What is scrollspy?
Scrollspy is a mechanism that highlights an active menu item based on current scroll position to indicate which section is currently visible in the viewport. It's available in Bootstrap (see the docs), but right now let's implement it from scratch using React and TypeScript.
Show me the code
Before we start, let's add some helper functions that we will use for computations in our hook. Also, this way we keep logic separated and make our code look cleaner.
// Restrict value to be between the range [0, value]
const clamp = (value: number) => Math.max(0, value);
// Check if number is between two values
const isBetween = (value: number, floor: number, ceil: number) =>
value >= floor && value <= ceil;
Once we're ready with helpers, we can jump to the hook code.
const useScrollspy = (ids: string[], offset: number = 0) => {
const [activeId, setActiveId] = useState("");
useLayoutEffect(() => {
const listener = () => {
const scroll = window.pageYOffset;
const position = ids
.map((id) => {
const element = document.getElementById(id);
if (!element) return { id, top: -1, bottom: -1 };
const rect = element.getBoundingClientRect();
const top = clamp(rect.top + scroll - offset);
const bottom = clamp(rect.bottom + scroll - offset);
return { id, top, bottom };
})
.find(({ top, bottom }) => isBetween(scroll, top, bottom));
setActiveId(position?.id || "");
};
listener();
window.addEventListener("resize", listener);
window.addEventListener("scroll", listener);
return () => {
window.removeEventListener("resize", listener);
window.removeEventListener("scroll", listener);
};
}, [ids, offset]);
return activeId;
};
As you can see this hook takes two arguments:
-
ids
- the list of sections IDs that we want to spy -
offset
- optional, offset from page top, by default set to0
Basically, all it does is:
- Calculating the top and bottom positions of spied sections
- Checking if current scroll position is between these two values
- Returning
id
of section which is currently in the viewport - Repeating the whole process on each scroll and resize event (since content height might change on window resize)
Also, notice that in this case instead of useEffect
we're using useLayoutEffect
, since it's better for DOM measurements. If you want to know more about differences between these two, I encourage you to read this great article by Kent C. Dodds.
The code should be self-explanatory, but if any part of it is unclear, let me know in the comments.
Demo
To see useScrollspy
in action, check out App
component in the sandbox below 👇
Thanks for reading! 👋
Posted on November 7, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.