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.
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.
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
andscrollCollapsedHeight
: Springs from framer-motion to handle scroll bar animation.
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]);
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]);
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)]
);
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.