From ce9695c6323b8b5acf180a0d2aa79f8efbad3877 Mon Sep 17 00:00:00 2001 From: Krishna Ayyalasomayajula Date: Mon, 1 Jun 2026 19:56:40 -0500 Subject: [PATCH] feat: add table of contents with scroll spy --- components/blog/TableOfContents.tsx | 68 +++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 components/blog/TableOfContents.tsx diff --git a/components/blog/TableOfContents.tsx b/components/blog/TableOfContents.tsx new file mode 100644 index 0000000..815c3bb --- /dev/null +++ b/components/blog/TableOfContents.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useEffect, useState, useRef } from "react"; + +interface TOCItem { + id: string; + text: string; + level: number; +} + +export function TableOfContents() { + const [headings, setHeadings] = useState([]); + const [activeId, setActiveId] = useState(""); + const observerRef = useRef(null); + + useEffect(() => { + const elements = Array.from( + document.querySelectorAll("article h2, article h3") + ) as HTMLElement[]; + + const parsed = elements.map((el) => ({ + id: el.id, + text: el.textContent ?? "", + level: el.tagName === "H2" ? 2 : 3, + })); + + setHeadings(parsed); + + observerRef.current = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setActiveId(entry.target.id); + } + }); + }, + { rootMargin: "-20% 0px -70% 0px", threshold: 0 } + ); + + elements.forEach((el) => observerRef.current?.observe(el)); + + return () => observerRef.current?.disconnect(); + }, []); + + if (headings.length === 0) return null; + + return ( + + ); +}