7 min read
How I Finally Made a this websites table of contents

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:

  1. Sections collapsing while you’re reading them (because the h2 scrolled away)
  2. Multiple sections expanding at once (when multiple headings were visible)
  3. Completely broken scrolling up (it would collapse everything)
  4. 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:

  1. Currently visible headings (for highlighting)
  2. Current h2 section (for expanding/collapsing)
  3. 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