↑ top
← Back to blog
EN | ES

Building a Table of Contents with IntersectionObserver (and the HTML Entity Bug That Bit Me)

How to build a sticky ToC that highlights the active section as you scroll, and the double-encoding bug that makes apostrophes show as ' in the browser.

✦ AI Summary

A table of contents that highlights the active section as you scroll is a small feature with an outsized UX impact on long posts. This site generates the ToC server-side, extracts headings from rendered HTML, and uses `IntersectionObserver` for the active-state tracking. There's a bug I hit during development that I haven't seen documented anywhere: HTML entities in headings get double-encoded and render literally in the browser.

Building a Table of Contents with IntersectionObserver (and the HTML Entity Bug That Bit Me)

A table of contents that highlights the active section as you scroll is a small feature with an outsized UX impact on long posts. This site generates the ToC server-side, extracts headings from rendered HTML, and uses IntersectionObserver for the active-state tracking.

There's also a bug I hit during development that I haven't seen documented anywhere: HTML entities in headings get double-encoded and render literally in the browser. Here's the full implementation and the fix.

Server-Side Heading Extraction

The ToC is generated in the Worker before the response is sent. marked.parse() converts the Markdown to HTML first, then extractHeadings() parses the rendered HTML to find <h2> and <h3> tags.

javascriptfunction extractHeadings(html) {
  const headings = [];
  const re = /<h([23])[^>]*>(.*?)<\/h\1>/gi;
  let m;
  while ((m = re.exec(html)) !== null) {
    const level = parseInt(m[1]);
    const text = m[2].replace(/<[^>]+>/g, "").trim()
      .replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">")
      .replace(/&quot;/g, '"').replace(/&#39;/g, "'");
    const id = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
    headings.push({ level, text, id });
  }
  return headings;
}

The heading IDs are derived from the text — the same logic marked uses to generate anchor IDs. A heading "What I'd Do Differently" becomes id what-id-do-differently. The ToC links use href="#what-id-do-differently" to scroll to the section.

The Double-Encoding Bug

Here's the bug: marked.parse() encodes special characters in heading text. An apostrophe ' in a heading becomes &#39; in the rendered HTML.

extractHeadings reads m[2] = What I&#39;d Do Differently. Without the entity decode step, escapeHtml(text) then converts the & in &#39; to &amp;, producing What I&amp;#39;d Do Differently.

In the browser, &amp;#39; renders as the literal string &#39; — not as an apostrophe. The ToC shows What I&#39;d Do Differently instead of What I'd Do Differently.

The fix is the entity decode before calling escapeHtml:

javascriptconst text = m[2].replace(/<[^>]+>/g, "").trim()
  .replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">")
  .replace(/&quot;/g, '"').replace(/&#39;/g, "'");

Now escapeHtml receives a clean string with a real apostrophe, and encodes it once as &#39; — which the browser renders correctly as '.

The CSS Layout

The ToC sits in a 1fr 200px CSS Grid alongside the post content:

css.post-layout {
  display: grid;
  grid-template-columns: 1fr 200px;
  gap: 3rem;
  align-items: start;
}

.toc-sidebar {
  position: sticky;
  top: 7rem;
  align-self: start;
  max-height: calc(100vh - 9rem);
  overflow-y: auto;
  border-left: 1px solid var(--border);
}

.toc-list a {
  display: block;
  padding: 0.3rem 0.5rem 0.3rem 1rem;
  color: #9b9ba8;
  font-size: 0.75rem;
  border-left: 1px solid transparent;
  margin-left: -1px;
}

.toc-list a.toc-active {
  color: var(--accent);
  border-left: 1px solid var(--accent);
}

The margin-left: -1px on active links overlaps the sidebar's left border with the accent border, creating a clean "track with indicator" effect without a double border.

On mobile, the sidebar hides and a collapsible <details> block replaces it above the post content.

IntersectionObserver for Active State

IntersectionObserver watches heading elements and updates the active ToC link as sections enter the viewport:

javascriptconst headingEls = document.querySelectorAll('h2[id], h3[id]');
const tocLinks = document.querySelectorAll('[data-toc]');

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      tocLinks.forEach(l => l.classList.remove('toc-active'));
      const active = document.querySelector(`[data-toc="${entry.target.id}"]`);
      if (active) active.classList.add('toc-active');
    }
  });
}, {
  rootMargin: '-10% 0px -80% 0px',
  threshold: 0
});

headingEls.forEach(h => observer.observe(h));

The rootMargin: '-10% 0px -80% 0px' shrinks the intersection detection zone to the top 20% of the viewport. A heading becomes "active" when it enters this zone — roughly when the user reaches that section, not when the heading is at the very bottom of the screen.

Without this margin, the observer fires too late: the heading has scrolled well past the top before the next section's heading enters the viewport.

What Doesn't Work Perfectly

The IntersectionObserver approach highlights the section that just entered the viewport from the top. If you scroll very fast, multiple sections can enter and exit before the observer callbacks fire, and the active state can lag.

A more robust approach uses a scroll listener that calculates which heading is closest to the top of the viewport on every scroll event. This is more CPU-intensive but more accurate. For a blog's ToC, the observer approach is good enough — readers scroll at human speeds.

The other edge case: the last section of a post may never trigger the observer if it's short and the page doesn't scroll far enough. The last heading's section needs to fill a significant portion of the viewport to intersect. For most posts, this isn't a problem. For posts where the last section is a single paragraph, the active state may stick on the second-to-last heading.

Was this helpful?