Skip to content

Instantly share code, notes, and snippets.

@samuelhorn
Last active May 28, 2024 13:40
Show Gist options
  • Save samuelhorn/557f1abbb7fefae321a84db331d6d459 to your computer and use it in GitHub Desktop.
Save samuelhorn/557f1abbb7fefae321a84db331d6d459 to your computer and use it in GitHub Desktop.

Here is the toc component

Let's break down the parts of the BlogTableOfContents component that are used to sync the scroll of the collapsed table of contents (TOC) with the scroll of the page, ensuring that the current element is always in view.

Scroll Sync Mechanism

State and References

State Variables:

  • headings: Stores an array of heading objects with their ids and indexes.
  • activeId: Keeps track of the currently active heading id.
  • tocCollapsed: Manages the collapsed state of the TOC.
  • innerContainerHeight: Holds the height of the inner content container.
  • bottom: Indicates if the user has reached the bottom of the TOC.

References:

  • headingsList: Refers to the list element containing all headings.
  • scrollRef: Stores the current scroll position.
  • innerContentContainerRef: Refers to the inner container holding the TOC content.
  • scrollBarHeight and scrollCollapsedHeight: Springs from framer-motion to handle scroll bar animation.

Intersection Observer

The intersection observer is used to detect which heading is currently in view as the user scrolls the page. It updates the activeId state to reflect the id of the currently visible heading.

useEffect(() => {
  const observer = new IntersectionObserver(
    (entries) => {
      entries.forEach((entry) => {
        const id = entry.target.getAttribute("id")!;

        if (entry.isIntersecting) {
          setActiveId(id);
          scrollRef.current = window.scrollY;
        } else {
          const diff = scrollRef.current - window.scrollY;
          const isScrollingUp = diff > 0;
          const currentIndex = headings.findIndex(
            (heading) => heading.id === id
          );
          const prevEntry = headings[currentIndex - 1];
          const prevId = prevEntry?.id;

          if (isScrollingUp && prevId) {
            setActiveId(prevId);
          }
        }
      });
    },
    {
      rootMargin: "0px 0px -85% 0px"
    }
  );

  const observeHeadings = () => {
    headings.forEach((heading) => {
      const currentHeading = document.getElementById(heading.id);

      if (currentHeading) {
        observer.observe(currentHeading as Element);
      }
    });
  };

  if (postContentLoaded) {
    setTimeout(observeHeadings, 0);
  }

  return () => {
    headings.forEach((heading) => {
      const currentHeading = document.getElementById(heading.id);

      if (currentHeading) {
        observer.unobserve(currentHeading as Element);
      }
    });
  };
}, [postContentLoaded]);

Scroll Position Calculation

The scroll position for the TOC is calculated using the scrollBarHeight and scrollCollapsedHeight springs. These values are updated based on the position of the active heading within the TOC.

useEffect(() => {
  const activeItem = document.querySelector(
    `[href='#${activeId}']`
  ) as HTMLAnchorElement;
  const lastItem = headingsList.current?.lastChild as HTMLLIElement;
  const activeParent = activeItem?.parentElement as HTMLLIElement;
  const activeParentOffset = activeParent?.offsetTop || 0;
  const activeParentHeight = activeParent?.clientHeight || 0;
  const activeParentCenter = activeParentOffset + activeParentHeight;

  if (lastItem === activeParent) {
    setTimeout(() => {
      setBottom(true);
    }, 200);
  } else if (lastItem !== activeParent && bottom) {
    setBottom(false);
  }

  scrollBarHeight.set(activeParentCenter);
  scrollCollapsedHeight.set(activeParentOffset);
}, [activeId]);

TOC Scrolling Animation

The TOC scrolling is animated using the useTransform hook from framer-motion. The tocScroll value is derived from the scrollCollapsedHeight, which is mapped to the vertical translation of the TOC.

const tocScroll = useTransform(
  scrollCollapsedHeight,
  [0, innerContainerHeight - 60],
  [0, -(innerContainerHeight - collapsedHeight)]
);

Putting It All Together

The motion.div wrapping the inner content container is animated with the tocScroll value to ensure that the active heading is always visible in the TOC.

<motion.div
  className="relative"
  ref={innerContentContainerRef}
  style={{
    y: tocCollapsed ? tocScroll : 0
  }}
>
  <ul className="list-none" ref={headingsList}>
    {slices.map((slice, i) => {
      if (
        slice.slice_type === "text_content" ||
        slice.slice_type === "faq"
      ) {
        return (
          <PrismicRichText
            key={i}
            field={
              slice.slice_type === "text_content"
                ? slice.primary.content
                : slice.primary.title
            }
            components={components}
          />
        );
      }
    })}
  </ul>
</motion.div>

By combining the intersection observer to track the active heading, calculating scroll positions, and animating the TOC's scroll with framer-motion, the component ensures that the current element is always in view, even when the TOC is collapsed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment