DEV Community

Rafał Goławski
Rafał Goławski

Posted on • Updated on

Highlight active menu item with scrollspy hook ⚛

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! 👋

Top comments (5)

Collapse
 
mikoloism profile image
mikoloism

Thanks you @rgolawski , this is useful 👌

Collapse
 
beinginvincible profile image
Ha__sh

Thanks Note - I was looking was a custom way to do this, your blog & sandbox really helped me to understand this way better. Love the efforts & easy explanation.

Collapse
 
andreygamb profile image
Andrey Zaynulin

Thank you a lot!

Collapse
 
mcochevwebolie profile image
miro

Thank you very much, save me all day. Be healthy!

Collapse
 
rgolawski profile image
Rafał Goławski

Thanks, I'm glad you find it useful!