Recently, I built a small React application that had a header, a navigation bar underneath and then the content. After scrolling past the header, the navigation bar was supposed to stay at the top and not scroll away.

Like so.

Making the navigation bar position: fixed; top 0; was not going to cut it because the header had to come first unless we scrolled past the navigation bar.

I wanted to keep the component that controls the layout as simple as possible, so I abstracted the stickiness logic into a useSticky hook. Here's how you'd use it.

import classNames from 'classnames';
import useSticky from './useSticky';

export default function App() {
  const { sticky, stickyRef } = useSticky();
  return (
    <>
      <header className="header">
        <h1>Header</h1>
      </header>
      <nav ref={stickyRef} className={classNames('nav', { sticky })}>
        Sticky nav
      </nav>
      <div
        style={{
          height: sticky ? `${stickyRef.current?.clientHeight}px` : '0px',
        }}
      />
      <main className="content" />
    </>
  );
}

You stick (no pun intended) the stickyRef into the thing that you want to fix to the top after you scroll past it. Then, sticky will indicate whether it should be fixed at the top or not. I then use that to apply a sticky class.

.sticky {
  position: fixed;
  top: 0;
}

The extra div below the nav adds some extra padding so that the content does not immediately jump below the navigation bar when this becomes fixed at the top.

🌟 The magic happens in the useSticky hook itself.

import { useEffect, useRef, useState } from 'react';

const useSticky = () => {
  const stickyRef = useRef(null);
  const [sticky, setSticky] = useState(false);
  const [offset, setOffset] = useState(0);

  useEffect(() => {
    if (!stickyRef.current) {
      return;
    }
    setOffset(stickyRef.current.offsetTop);
  }, [stickyRef, setOffset]);

  useEffect(() => {
    const handleScroll = () => {
      if (!stickyRef.current) {
        return;
      }

      setSticky(window.scrollY > offset);
    };
    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, [setSticky, stickyRef, offset]);
  return { stickyRef, sticky };
};

export default useSticky;

The first effect measures the vertical offset of the element we want to fix at the top after it's scrolled past. The second one listens to the scroll event and determines whether we have scrolled past the element's top edge and it needs to become sticky.

🙌 Hope this is useful!