Highlight active menu item with scrollspy hook ⚛

Rafał Goławski - Nov 7 '21 - - Dev Community

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;
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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 to 0

Basically, all it does is:

  1. Calculating the top and bottom positions of spied sections
  2. Checking if current scroll position is between these two values
  3. Returning id of section which is currently in the viewport
  4. 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! 👋

. . . . . . . . . . . .