Table of Contents with IntersectionObserver

Prefer a video version? You can watch it on YouTube here.

This post covers a modern way of automatically creating a table of contents for your blog post that updates with a ‘current position’ indicator. If you are on a ‘desktop’ size screen, I’m talking about the ‘quick links’ you can see below the advert thing on the right of this very post. Clicking a link lets you jump to a section and when that section is in view we highlight it so you can see where you are in the post.

I’ve written about building a table of contents for a blog post before. They are a great piece of progressive enhancement to add with JS; the feature is great if there but it doesn’t ruin the core experience if it isn’t. We write this JavaScript once and then it creates it all for us on every post we have.

Now, I’ve had some version of this functionality on my site since around 2015. Back then, this is what the code looked like. It’s 100+ lines here so you may want to just skip down!

;(function (doc, proto) { try { // check if browser supports :scope natively doc.querySelector(":scope body"); } catch (err) { // polyfill native methods if it doesn"t ["querySelector", "querySelectorAll"].forEach(function (method) { var native = proto[method]; proto[method] = function (selectors) { if (/(^|,)\s*:scope/.test(selectors)) { // only if selectors contains :scope var id = this.id; // remember current element id this.id = "ID_" + Date.now(); // assign new unique id selectors = selectors.replace(/((^|,)\s*):scope/g, "$1#" + this.id); // replace :scope with #ID var result = doc[method](selectors); this.id = id; // restore previous id return result; } else { return native.call(this, selectors); // use native code for other selectors } }; }); }
})(window.document, Element.prototype); var documentHeight = document.body.clientHeight;
var setOfPos = [];
var currentHtag = []; var wrappingElement = document.querySelector(".post-Content");
if (wrappingElement !== null) { var allHtags = wrappingElement.querySelectorAll(":scope > h1, :scope > h2");
} function debounce(func, wait, immediate) { var timeout; return function() { var context = this, args = arguments; var later = function() { timeout = null; if (!immediate) func.apply(context, args); }; var callNow = immediate && !timeout; clearTimeout(timeout); timeout = setTimeout(later, wait); if (callNow) func.apply(context, args); };
} var debouncerMap = debounce(function() { site.createMapOfPositions();
}, 250); var debouncerMeasure = debounce(function() { site.measureAndCheck();
}, 100); var site = { createTOC: function () { var frag = document.createDocumentFragment(); var jsNav = document.createElement("nav"); jsNav.classList.add("toc-Wrapper"); var theBody = document.querySelector("body"); Array.prototype.forEach.call(allHtags, function (el, i) { var links = document.createElement("a"); links.setAttribute("href", "#h-" + el.tagName + "_" + i); links.classList.add("toc-Link"); links.classList.add("toc-Link_" + el.tagName); var textContentOfLink = el.textContent; el.id = "h-" + el.tagName + "_" + i; links.textContent = textContentOfLink; frag.appendChild(links); }); jsNav.appendChild(frag); theBody.appendChild(jsNav); }, createMapOfPositions: function () { var scrollPositionIs = window.scrollY; setOfPos = []; Array.prototype.forEach.call(allHtags, function (el) { var eachHpos = (el.getBoundingClientRect().top + scrollPositionIs); setOfPos.push(eachHpos); currentHtag.push(el.tagName); }); setOfPos.push(documentHeight); }, measureAndCheck: function () { var windowHeight = window.innerHeight; var scrollPositionIs = window.scrollY; var windowAndScroll = windowHeight + scrollPositionIs; var theTOC = document.querySelector(".toc-Wrapper"); var items = theTOC.querySelectorAll("a"); Array.prototype.forEach.call(items, function (el) { if (el.classList.contains("current")) { el.classList.remove("current"); } }); for (var i = 0; i < setOfPos.length; i++) { if ((windowAndScroll >= setOfPos[i]) && (windowAndScroll <= setOfPos[i + 1])) { theTOC.querySelectorAll("a")[i].classList.add("current"); } } }, listenForScroll: function () { window.addEventListener("resize", function () { debouncerMap(); debouncerMeasure(); }, false); window.addEventListener("scroll", function () { debouncerMeasure(); }, false); window.addEventListener("onhashchange", function () { site.createMapOfPositions(); site.measureAndCheck(); }, false); }
}; ;(function setUp(){ if (wrappingElement === null) { return; } site.createTOC(); site.createMapOfPositions(); site.measureAndCheck(); site.listenForScroll();
})(); // End

Without taking you through it line by line, the idea was, I find all the H1 and H2 elements in the document and make an array of their positions. Then I have a scroll listener that gets debounced (if you don’t know what a debounce is, essentially it’s a function to stop a listener firing a ridiculous number of times) and reads the users current scroll position. It then makes the H1 or H2 that is currently positioned closest to it active/current — and any that are no longer closest inactive.

And that prior code has worked, generally fine, for the last 5 or so years. However, when I dipped into that file for something recently, I realised there were huge economies in code and performance to make by switching to using Intersection Observers. This post is really just a celebration of things that are now straightforward in JavaScript in 2021. I’m in no way trying to sell this as the absolute best way to this but it’s working well for me — as ever, let me know my mistakes in the comments!

Here’s the 2021 version. Even with a few comments in, it’s still significantly shorter; and more importantly, understand:

var insertNode = document.querySelector(".post-PimpingAintEasy"); // Wrapper for Blog post
var wrappingElement = document.querySelector(".post-Content"); // Get all H1/H2 tags from the post
var allHtags = wrappingElement.querySelectorAll(":scope > h1, :scope > h2"); // Intersection Observer Options
var options = { root: null, rootMargin: "0px", threshold: [1],
}; // Each Intersection Observer runs setCurrent
var observeHtags = new IntersectionObserver(setCurrent, options); // Build the DOM for the menu
function createTOC() { var frag = document.createDocumentFragment(); var jsNav = document.createElement("nav"); jsNav.classList.add("toc-Wrapper"); var tocTitle = document.createElement("h4"); tocTitle.classList.add("toc-Title"); tocTitle.textContent = "Sections"; jsNav.appendChild(tocTitle); allHtags.forEach((el, i) => { var links = document.createElement("a"); links.setAttribute("href", "#h-" + el.tagName + "_" + i); links.classList.add("toc-Link"); links.classList.add("toc-Link_" + el.tagName); var textContentOfLink = el.textContent; el.id = "h-" + el.tagName + "_" + i; links.textContent = textContentOfLink; frag.appendChild(links); }); jsNav.appendChild(frag); insertNode.appendChild(jsNav); // Now allHtags.forEach(tag => { observeHtags.observe(tag); });
} // Function that runs when the Intersection Observer fires
function setCurrent(e) { console.log(e); var allSectionLinks = document.querySelectorAll(".toc-Link"); e.map(i => { if (i.isIntersecting === true) { allSectionLinks.forEach(link => link.classList.remove("current")); document.querySelector(`a[href="#${i.target.id}"]`).classList.add("current"); } })
} (function setUp() { if (wrappingElement === null) { return; } createTOC();
})();

First off, I’ve got a polyfill in there for ‘scopes’ I can pull out (see ya IE11!).

I’m not going to use any scroll listeners so they can come out. In turn that means I don’t need the debounce function either.

I do still want the section where I make the DOM for the table of contents. Other that the fact I’ve added a heading that bit is fine.

The real joyous bit for me is the Intersection Observers. I love these things. They make anything that I used to write involving scroll listeners 100x better and easier.

If you are unfamiliar with Intersection Observers, these little beauties are a revelation. They are a new-ish DOM feature that will observe things in the DOM and tell you when they intersect another. I know — it’s almost like someone thought about this kind of use case!

So, the crux of this code is getting our Intersection Observers to watch the H1 and H2 elements on the blog post and letting the Observer tell us when they intersect the viewport. Then all we need to do is toggle a class when they do.

When you use an Intersection Observer, you typically want to provide a set of options. Here is what I have:

// Intersection Observer Options
var options = { root: null, rootMargin: "0px", threshold: [1],
};

You can tweak all manner of things about an intersection Observer, including what thresholds it will notify you. I have it set to ‘1’ which means I only care when the whole thing I’m observing intersects the viewport. You could add many other points like .5 and .7 for 50% and 70% and whe Intersection Observers will dutifully at all those points.

I don’t want any rootMargin — that would be useful if I wanted something to start before it came into view, like 50px away from intersecting the viewport.

Although I understand the concepts of Intersection Observers fine, writing them always confuses me when I come to writing them:

var wrappingElement = document.querySelector(".post-Content");
var observeHtags = new IntersectionObserver(setCurrent,options); if (wrappingElement !== null) { var allHtags = wrappingElement.querySelectorAll(":scope > h1, :scope > h2"); allHtags.forEach(tag=>{ observeHtags.observe(tag); });
}

Here is my explatation for the unintiated. We set up each Intersection Observer by first assigning a variable to a new Intersection Observer which is going to fire the first argument — a function on the basis of the options we have specified in the second argument, which is the options we looked at before.

With that set up, for each H1 and H2 (assigned to the allHtags we iterate over) we are adding one of those Intersection Observers.

Now the function to toggle the active class is quite simple. Because the Intersection Observer might be telling you about multiple things it returns an array. So our function maps over each and if the Observer says it is intersecting, we find our quicklink tag by finding a href that matches the id of the H tag and adding a current class. Just before we do that we want all the existing links to have their ‘current’ class removed. This means that a section link stays current if it’s a long section and the H tag has long since scrolled off and is no longer intersecting:

// Function that runs when the Intersection Observer fires
function setCurrent(e) { console.log(e); var allSectionLinks = document.querySelectorAll(".toc-Link"); e.map(i => { if (i.isIntersecting === true) { allSectionLinks.forEach(link => link.classList.remove("current")); document.querySelector(`a[href="#${i.target.id}"]`).classList.add("current"); } })
}

So that’s essentially is. Thanks to Intersection Observer we have a small but very efficient bit of code to create our table of contents, provide quick links to jump around the document and update readers on where they are in a document as they read.

Suggestions? Improvements? Please let me know in the comments!

As Lukas pointed out in the comments, and Aleks commented via Twitter, with what I had, coming down the page was optimal, but scrolling back up wasn’t as good. I was aware of this shortcoming and was happy enough with it for my scenario. I still feel that approach is light and ‘good enough’ for many applications. However, I was curious how little extra code I would need to make it work better going up the page too. It felt, intuitively, like it should be straightforward enough but it took me a couple of hours to get something better than what I already had. What you see on the site currently includes the extra stuff below.

The first thing I needed to do was establish what the problem was. Everything was fine until a user scrolled back up the page and the H tag that was ‘current’ scrolled back off the bottom of the viewport. In that scenario, it meant, from a users POV, the previous section was now ‘current’. In the original 2021 version, the ‘current’ indicator wouldn’t change until a prior H tag was scrolled in from the top.

To make the ‘current’ update to the prior block could have been solved if I could easily wrap each section and add Observers to those instead but that wasn’t on the cards in my scenario. I write posts in Sublime as markdown, then use Pandoc to convert them to HTML which I paste into the WordPress classic editor. I wasn’t prepared to upend that workflow.

So, I needed to figure this out with the structure I had.

My first instinct was that I needed to establish which direction the user was headed in. That’s something I’ve looked at previously with Intersection Observers. But that wasn’t actually the issue at hand.

The edge case was, when a H tag stops intersecting the viewport at the bottom. If it stops intersecting at the top, then the user is scrolling down. I needed to determine whether the intersection was occurring at the top or bottom. So how can we solve/determine this with Intersection Observers?

What we can do is look at the rootBounds.bottom of the IntersectionObserver event. This gives us the height of the root — in our case the viewport. We can then ask if that viewport minus the rectangle position of the element we are observing (the H tag that just entered or left), is greater than half the viewport height. If it is, then the intersection is happening at the top, if it isn’t, we are at the bottom. So our little tests returns with a boolean.

function didThisIntersectionHappenAtTop(i) { return i.rootBounds.bottom - i.boundingClientRect.bottom > i.rootBounds.bottom / 2 ? true : false }

The other piece of the puzzle is that we need to know when an event is ‘stopping’ intersecting. More simply, it was intersecting, but now it isn’t. This is exactly what isIntersecting is for:

If this is true, then, the IntersectionObserverEntry describes a transition into a state of intersection; if it’s false, then you know the transition is from intersecting to not-intersecting.

We now, by knowing if the intersection event occurred at the bottom of the viewport, and if it was a situation where the intersection was moving to a state of non-intersection, have the ability to detect the circumstances when we want to do something different.

What I decided we wanted to do when a H tag was scrolled back off the bottom was make the previous H tag current. Does that mean more DOM reads to find the prior element that was either H1 or H2? Because the heading tags sit alongside p tags, images, blockquotes and the like I would have to use a function to do that. Luckily, it’s easier than that.

We already have a reference to all the H tags. I can find the index of the current H tag by iterating over the Array of headings and finding the one that is the same as the one this event is occurring on. Here’s the function that returns the elements index:

function getHeadingIndex(i) { let priorEle = (ele) => ele === i.target; return allHtags.findIndex(priorEle); }

And it gets called like this:

document.querySelector(`a[href="#${allHtags[indexOfThisHeading - 1].id}"]`).classList.add("current");

So we get the current count and then take one away to get the prior one.

At this point scrolling up was working better. I was pretty happy with the code despite the additional complexity of the nice clean initial solution.

However, when a page loads with Intersection Observers applied, they all run initially. This causes a problem given the logic I have because it will loop through the H tags, find ones that are not intersecting and not at the top and apply a current class to the prior heading element. So, on initial load, the second to last heading in the table of contents was getting the ‘current’ treatment. Grrrr.

What I have ended up doing is excluding that happening for any Intersection Observers that occur within the first second. I don’t need a setTimeout here as we get a time property.

The IntersectionObserverEntry interface’s read-only time property is a DOMHighResTimeStamp that indicates the time at which the intersection change occurred relative to the time at which the document was created.

I’m not crazy about this. Feels a bit brittle. There must be a better way?

In the absence of anything better, here is the setCurrent function now with all the additional logic and the extra little functions needed:

function didThisIntersectionHappenAtTop(i) { return i.rootBounds.bottom - i.boundingClientRect.bottom > i.rootBounds.bottom / 2 ? true : false } function getPriorHeading(i) { let priorEle = (ele) => ele === i.target; return allHtags.findIndex(priorEle); } // Function that runs when the Intersection Observer fires
function setCurrent(e) { var allSectionLinks = document.querySelectorAll(".toc-Link"); e.map(i => { let top = didThisIntersectionHappenAtTop(i); // Page just loaded ... probably and a heading is in view if (i.time < 1000 && i.isIntersecting) { document.querySelector(`a[href="#${i.target.id}"]`).classList.add("current"); } else if (i.time < 1000) { // In this case page just loaded and no heading in view return; } else if (!top && i.isIntersecting === false) { // This section deals with scrolling up the page. First we find if the heading being scrolled off the bottom is the first H tag in source order. let indexOfThisHeading = getPriorHeading(i); if (indexOfThisHeading === 0) { // The first H tag just scrolled off the bottom of the viewport and it is the first H tag in source order allSectionLinks.forEach(link => link.classList.remove("current")); } else { // An H tag scrolled off the bottom. It isn't the first so make the previous heading current allSectionLinks.forEach(link => link.classList.remove("current")); document.querySelector(`a[href="#${allHtags[indexOfThisHeading - 1].id}"]`).classList.add("current"); } } else if (i.isIntersecting) { // For all other instances we want to make this one current and the others not current allSectionLinks.forEach(link => link.classList.remove("current")); document.querySelector(`a[href="#${i.target.id}"]`).classList.add("current"); } })
}

This is certainly ‘better’ than the original functionality but I feel it only just warrants the extra code. Plus, that initial clause to disregard the initial events feels skanky. It feels like there is something cleaner just out of reach. Something that, for now at least, eludes this author.

Leave a Reply

Your email address will not be published. Required fields are marked *