Auto Highlight Current Heading Scrolling (Scrollspy)

Santaji GadeTechnical SEO3 days ago12 Views

highlight current heading scrolling

Highlight the current heading while scrolling with IntersectionObserver instead of scroll listeners — plus the last-section edge case most scripts miss.

Technical SEO Scrollspy JavaScript 2026

Auto highlighting the current heading while scrolling, often called "scrollspy," works by watching which section is currently visible in the viewport and marking the matching table of contents link as active. The modern, efficient way to do this uses IntersectionObserver rather than a scroll event listener.

Andrew Gilliland's guide on the Intersection Observer API explains the core mechanism: an IntersectionObserver watches one or more elements and fires a callback whenever their intersection with a root element, the viewport by default, crosses a threshold you define. Scroll listeners, by contrast, fire dozens of times per second and force expensive layout recalculations if not carefully throttled.

🔎 Did you know?

The term "ScrollSpy" actually originated with the Bootstrap framework, which used it for a jQuery-based navigation mechanism that watched scroll position and updated active links. Modern implementations swap Bootstrap's original scroll-math approach for IntersectionObserver, but the name has stuck across the JavaScript ecosystem.

This pattern works best paired with a table of contents generated from your page's actual headings, since scrollspy highlighting has nothing to highlight without a linked navigation list already in place.

Here's the working code for basic highlighting, handling the tricky edge case of short sections near the bottom of a page, and a version that accounts for a fixed header.

0
scroll event listeners needed when using IntersectionObserver instead
-70%
typical rootMargin offset used to trigger highlighting before a section reaches page center
1
active class applied at a time, removed from all others on every intersection change
Advertisement
Advertisement

01Basic Scroll Highlighting With IntersectionObserver

The core pattern: observe every heading in the content, and whenever one crosses the intersection threshold, remove the active class from all table of contents links and add it to the one matching that heading's ID.

Basic Scrollspy Highlighting
function highlightActiveHeading(contentSelector, tocSelector) {
  const headings = document.querySelectorAll(`${contentSelector} h2, ${contentSelector} h3`);
  const tocLinks = document.querySelectorAll(`${tocSelector} a`);

  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        tocLinks.forEach(link => link.classList.remove('active'));

        const activeLink = document.querySelector(
          `${tocSelector} a[href="#${entry.target.id}"]`
        );
        if (activeLink) activeLink.classList.add('active');
      }
    });
  }, {
    rootMargin: '0px 0px -70% 0px'
  });

  headings.forEach(heading => observer.observe(heading));
}

highlightActiveHeading('.article-content', '#toc');

02Why the rootMargin Value Matters

The rootMargin option shrinks the effective viewport used for intersection detection. Andrew Gilliland's guide, referenced above, explains it directly: a bottom value of "-70% 0px" shrinks the bottom of the detection zone by 70%, so only headings in the top 30% of the viewport are considered active, which makes the highlight feel like it tracks where a reader is actually looking.

Without this adjustment, a heading barely peeking into view at the bottom of the screen would trigger highlighting prematurely, well before a reader has actually scrolled to that section.

💡 Quick Tip

Start with -70% and adjust from there by watching your actual page in DevTools. A value that feels right on a short blog post can feel too aggressive on a dense documentation page with many closely-spaced headings, so treat it as a tunable setting, not a fixed constant.

Advertisement
Advertisement

03Handling the Short Last Section Edge Case

Ben Frain's writeup on building a table of contents with the Intersection Observer API documents this exact edge case: when a heading tag stops intersecting the viewport at the bottom versus the top, the correct behavior differs. A common bug is that the final heading on a page is often too short to ever cross a 70% rootMargin threshold, since there isn't enough content below it to push the viewport that far. This version explicitly checks for scroll position near the bottom of the page and force-activates the last heading.

Scrollspy With Last-Section Fix
function highlightActiveHeadingSafe(contentSelector, tocSelector) {
  const headings = Array.from(
    document.querySelectorAll(`${contentSelector} h2, ${contentSelector} h3`)
  );
  const tocLinks = document.querySelectorAll(`${tocSelector} a`);

  function setActive(id) {
    tocLinks.forEach(link => link.classList.remove('active'));
    const activeLink = document.querySelector(`${tocSelector} a[href="#${id}"]`);
    if (activeLink) activeLink.classList.add('active');
  }

  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        setActive(entry.target.id);
      }
    });
  }, { rootMargin: '0px 0px -70% 0px' });

  headings.forEach(heading => observer.observe(heading));

  // Force-activate the last heading when the page is scrolled to the bottom
  window.addEventListener('scroll', () => {
    const scrolledToBottom =
      window.innerHeight + window.scrollY >= document.body.scrollHeight - 10;

    if (scrolledToBottom && headings.length) {
      setActive(headings[headings.length - 1].id);
    }
  }, { passive: true });
}

04Accounting for a Fixed Header

Maxime Heckel's deep-dive on scrollspy implementation covers this directly: the Intersection Observer API's rootMargin option adds margins around the viewport before computing intersection, so a fixed header can be accounted for by adding a negative top margin equal to the header's height, making the intersection end earlier by that same amount.

Scrollspy Adjusted for a Fixed Header
function highlightWithFixedHeader(contentSelector, tocSelector, headerHeight = 80) {
  const headings = document.querySelectorAll(`${contentSelector} h2, ${contentSelector} h3`);
  const tocLinks = document.querySelectorAll(`${tocSelector} a`);

  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        tocLinks.forEach(link => link.classList.remove('active'));
        const activeLink = document.querySelector(
          `${tocSelector} a[href="#${entry.target.id}"]`
        );
        if (activeLink) activeLink.classList.add('active');
      }
    });
  }, {
    // Offset the top by the header height, bottom by 70% as before
    rootMargin: `-${headerHeight}px 0px -70% 0px`
  });

  headings.forEach(heading => observer.observe(heading));
}

highlightWithFixedHeader('.article-content', '#toc', 80);
Advertisement
Advertisement

05Styling the Active State

The JavaScript only toggles a class, actual visual styling belongs in CSS. A simple, effective active-link style just needs a color and weight change, plus a smooth transition.

If your active links are pointing at broken or renamed anchor IDs, they'll silently fail to highlight at all, so it's worth periodically running a broken link check across your site to catch stale anchors before they quietly break navigation.

Basic Active-Link CSS
#toc a {
  display: block;
  padding: 4px 0;
  color: #5a6570;
  text-decoration: none;
  transition: color 0.2s ease, font-weight 0.2s ease;
}

#toc a.active {
  color: #4a9e24;
  font-weight: 700;
  border-left: 2px solid #4a9e24;
  padding-left: 8px;
}

06Approach Comparison

A quick reference comparing scroll listeners to IntersectionObserver.

ApproachPerformanceComplexity
Scroll event listenerFires constantly, needs manual throttlingSimple but easy to get wrong
IntersectionObserverAsync, off main thread, efficient by defaultSlightly more setup, no throttling needed
Scroll listener + requestAnimationFrameBetter than raw scroll, still main-thread workModerate, manual frame management

07Practical Notes Before Shipping

A few things worth checking before this goes into production.

Use IntersectionObserver over scroll listeners, it avoids constant recalculation and runs more efficiently.

Tune the rootMargin to your layout, the right offset depends on your header height and desired trigger point.

Handle the short last-section edge case, otherwise the final heading may never get highlighted.

Keep styling in CSS, not JavaScript, the script should only toggle a class.

Test with a fixed header if you have one, headings can scroll underneath it before appearing visually active.

08Common Questions

IntersectionObserver runs asynchronously off the main thread and doesn't require manual throttling, making it significantly more efficient than a scroll listener that fires dozens of times per second.

A short final section often doesn't have enough content below it to cross the rootMargin threshold. A scroll-to-bottom check that force-activates the last heading fixes this.

It shrinks the effective viewport used for intersection detection, letting you control exactly when a heading is considered "in view" relative to the actual visible screen area.

Yes. Offset the top rootMargin value by your header's pixel height, otherwise headings will appear active while still partially hidden underneath the fixed header.

CSS. The JavaScript should only add or remove a class name; all visual styling, colors, transitions, and borders belong in your stylesheet.

What We Learn Today

IntersectionObserver beats scroll listeners for performance

rootMargin controls exactly when a heading counts as active

Short last sections need a manual scroll-to-bottom fix

Fixed headers require offsetting the top rootMargin value

JavaScript should only toggle classes, not apply styles directly

One active class should be applied at a time, removed from all others first

Build a Complete Content Navigation Toolkit

Scrollspy highlighting pairs directly with table of contents generation for long-form content. Explore both guides next.

0 Votes: 0 Upvotes, 0 Downvotes (0 Points)

Leave a reply

Loading Next Post...
Search
Popular Now
Loading

Signing-in 3 seconds...

Signing-up 3 seconds...