How I Finally Made a this websites table of contents
I just wanted a table of contents that would track where you are on the page. Sounds simple right? Narrator: it wasn’t.
The Initial Setup
Astro gives you headings in a nice flat array:
interface Heading {
depth: number; // 2 for h2, 3 for h3, etc.
slug: string; // the ID
text: string; // the actual heading
}
// So your data looks like:
[
{ depth: 2, slug: 'introduction', text: 'Introduction' },
{ depth: 3, slug: 'getting-started', text: 'Getting Started' },
{ depth: 3, slug: 'prerequisites', text: 'Prerequisites' },
{ depth: 2, slug: 'main-content', text: 'Main Content' },
// ...and so on
]
But I needed to nest them properly because h3s should be under h2s. First attempt:
headings.reduce((acc, heading) => {
if (heading.depth === 2) {
acc.push(heading); // Add h2s to the root level
} else {
// Try to add it to the last h2's children array
const lastH2 = acc[acc.length - 1];
lastH2.children.push(heading); // 💥 This explodes
}
return acc;
}, []);
TypeError. Of course - headings don’t have a children array. The interface doesn’t know about nesting. Had to make a new type:
interface GroupedHeading extends Heading {
children: Heading[];
}
// Now each h2 would look like:
{
depth: 2,
slug: 'introduction',
text: 'Introduction',
children: [
{ depth: 3, slug: 'getting-started', text: 'Getting Started' },
{ depth: 3, slug: 'prerequisites', text: 'Prerequisites' }
]
}
The Scrolling Problem
IntersectionObserver seemed perfect for tracking position. Just watch headings come in and out of view. Instead I got:
- Sections collapsing while you’re reading them (because the h2 scrolled away)
- Multiple sections expanding at once (when multiple headings were visible)
- Completely broken scrolling up (it would collapse everything)
- Active sections jumping around (as different headings entered/left view)
First attempt was naive:
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// Heading is visible, make it active
entry.target.classList.add('active');
} else {
// Heading left view, deactivate it
entry.target.classList.remove('active');
// But wait - what if we're still in this section?
// What if there are multiple sections visible?
// What about subheadings?
// 💥 Everything breaks
}
});
});
This only worked if you scrolled down slowly and your sections were tiny. Real usage? Chaos. Why? Because being “in a section” isn’t just about whether its heading is visible - it’s about context.
The Solution
Turned out I needed to track three things:
- Currently visible headings (for highlighting)
- Current h2 section (for expanding/collapsing)
- Previous section (for smooth transitions when scrolling)
let activeHeadings = new Set<string>(); // All visible heading IDs
let currentSection: string | null = null; // Current h2's ID
let previousSection: string | null = null; // Last h2's ID for transitions
// So when reading a section about "Installation":
activeHeadings = new Set(['installation', 'step-1', 'step-2']);
currentSection = 'installation';
previousSection = 'introduction';
Plus tracking which subheadings belonged to which sections:
const headingToSection = new Map<string, string>();
tocLinks.forEach(link => {
const parentSection = link.getAttribute('data-parent');
if (parentSection) {
const id = link.getAttribute('href')?.slice(1);
if (id) headingToSection.set(id, parentSection);
}
});
// Creates a mapping like:
// 'step-1' -> 'installation'
// 'step-2' -> 'installation'
// So when 'step-1' comes into view, we know to keep
// the 'installation' section expanded
This let me find parent sections when subheadings came into view. Now when you scroll to “Step 1”, the code knows it’s part of “Installation” and keeps that section expanded.
The Scroll Up Problem
Scrolling up was the real pain. When you scroll up past a section break, you need to figure out what section you’re actually in. It’s not just “what headings are visible” - you need to understand where you are in the document flow.
// Find all visible h2s and sort them by position
const visibleH2s = Array.from(newActiveHeadings).filter(heading => {
const element = document.getElementById(heading);
// Only care about h2s, ignore subheadings
return element?.tagName.toLowerCase() === 'h2';
}).sort((a, b) => {
// Sort by their position in the document
const posA = document.getElementById(a)?.getBoundingClientRect().top || 0;
const posB = document.getElementById(b)?.getBoundingClientRect().top || 0;
return posA - posB;
// Now we have ['introduction', 'installation', 'usage']
// in document order
});
But what if you scrolled up past all h2s? Need a fallback:
if (!lastVisibleH2) {
// Find the next h2 that's below the viewport
const nextH2 = headings.find(h =>
h.tagName.toLowerCase() === 'h2' &&
h.getBoundingClientRect().top > 0 // Positive means below viewport
)?.id;
if (nextH2) {
// We're in the section before this one
previousSection = currentSection;
currentSection = nextH2;
}
// So if you're between "Installation" and "Usage",
// currentSection = "Usage" even though you haven't reached it
// This keeps the right section expanded
}
Making It Smooth
The expand/collapse animation initially made content jump around. CSS transitions fixed this:
.toc-sublist {
max-height: 0; /* Start collapsed */
opacity: 0; /* And invisible */
overflow: hidden; /* Hide overflowing content */
/* Smooth transition for both height and opacity */
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
.toc-sublist.expanded {
max-height: 500px; /* Arbitrary large value */
opacity: 1; /* Fade in */
}
/* So when you add/remove 'expanded':
- Height animates from 0 to 500px (or vice versa)
- Content fades in/out
- Cubic bezier gives a nice easing effect
*/
The max-height
transition is hacky - you’re basically saying “this won’t ever be taller than 500px”. But it works because CSS can’t transition from height: auto
.
Click Navigation
Click handling needed to do a lot. Here’s what happens when you click a link:
function smoothScroll(e: Event) {
e.preventDefault(); // Stop default jump-to-anchor behavior
const target = e.currentTarget as HTMLAnchorElement;
const targetId = target.getAttribute("href");
if (!targetId) return;
const targetElement = document.querySelector(targetId);
if (!targetElement) return;
// Scroll to target, accounting for fixed header
window.scrollTo({
top: targetElement.offsetTop - scrollOffset, // scrollOffset is header height
behavior: "smooth" // Animate the scroll
});
// If clicking a subheading, expand its section
const parentSection = target.getAttribute('data-parent');
if (parentSection) {
updateSection(parentSection); // This adds the 'expanded' class
}
// Add visual feedback for where you landed
targetElement.classList.add("highlight");
setTimeout(() => {
targetElement.classList.remove("highlight");
}, 2000); // Highlight fades after 2 seconds
}
The highlight animation looks like this:
.highlight {
animation: highlight-pulse 2s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes highlight-pulse {
0% { background-color: transparent; }
25% { background-color: rgb(214 211 209 / 0.2); }
75% { background-color: rgb(214 211 209 / 0.2); }
100% { background-color: transparent; }
}
/* Creates a subtle pulse effect when you land on a section */
The Final Result
After a day of obsessing over this, I have a table of contents that:
- Tracks position properly (using IntersectionObserver)
- Doesn’t jump around (thanks to CSS transitions)
- Animates smoothly (with proper easing functions)
- Works with keyboard navigation (all standard anchor behavior preserved)
- Doesn’t break when scrolling up (with proper section tracking)
- Actually helps navigate the page (with visual feedback)
For mobile? It just hides:
@media (max-width: 1280px) {
.toc {
@apply hidden;
}
}
I have an actually normal drawer based mobile one. Thanks shadcn