Implementing Notion-like table of contents in JavaScript

Notion-like table of contents in JavaScript

Long web pages benefit from having a table of contents.
Especially technical, reference documentation. As a reader you want a way to quickly navigate to a specific part of the documentation.
This article describes how I implemented table of contents for documentation page for my Edna note taking application.
Took only few hours. Here’s full code.

A good toc

A good table of contents is:
Table of contents cannot be always visible. Space is always at premium and should be used for the core functionality of a web page.
For a documentation page the core is documentation text so space should be used to show documentation.
But it should always be available in some compact form.
I noticed that Notion implemented toc in a nice way. Since great artists steal, I decided to implement similar behavior for Edna’s documentation
When hidden, we show mini toc i.e. for each toc entry we have a gray rectangle. A block rectangle indicates current position in the document:
It’s small and unobtrusive:
When you hover mouse over that area we show the actual toc:
Clicking on a title goes to that part of the page.

Implementing table of contents

My implementation can be added to any page.

Grabbing toc elements

I assume h1 to h6 elements mark table of contents entries. I use their text as text of the entry.
After page loads I build the HTML for the toc.
I grab all headers elements:
function getAllHeaders() {
  return Array.from(document.querySelectorAll("h1, h2, h3, h4, h5, h6"));
}
Each toc entry is represented by:
class TocItem {
  text = "";
  hLevel = 0;
  nesting = 0;
  element;
}
text we show to the user. hLevel is 16 for h1h6. nesting is like hLevel but sanitized. We use it to indent text in toc, to show tree structure of the content.
element is the actual HTML element. We remember it so that we can scroll to that element with JavaScript.
I create array of TocItem for each header element on the page:
function buildTocItems() {
  let allHdrs = getAllHeaders();
  let res = [];
  for (let el of allHdrs) {
    /** @type {string} */
    let text = el.innerText.trim();
    text = removeHash(text);
    text = text.trim();
    let hLevel = parseInt(el.tagName[1]);
    let h = new TocItem();
    h.text = text;
    h.hLevel = hLevel;
    h.nesting = 0;
    h.element = el;
    res.push(h);
  }
  return res;
}

function removeHash(str) {
  return str.replace(/#$/, "");
}

Generate toc HTML

Toc wrapper

Here’s the high-level structure:
.toc-wrapper has 2 children:
Wrapper is always shown on the right upper corner using fixed position:
.toc-wrapper {
  position: fixed;
  top: 1rem;
  right: 1rem;
  z-index: 50;
}
You can adjust top and right for your needs.
When toc is too long to fully shown on screen, we must make it scrollable. But also default scrollbars in Chrome are large so we make them smaller and less intrusive:
.toc-wrapper {
  position: fixed;
  top: 1rem;
  right: 1rem;
  z-index: 50;
}
When user hovers over .toc-wrapper, we switch display from .toc-mini to .toc-list:
.toc-wrapper:hover > .toc-mini {
  display: none;
}

.toc-wrapper:hover > .toc-list {
  display: flex;
}

Generate mini toc

We want to generate the following HTML:
<div class="toc-mini">
  <div class="toc-item-mini toc-light"></div>
  ... repeat for every TocItem
</div>
is a Unicode characters that is a filled rectangle of the bottom 30% of the character.
We use a very small font becuase it’s only a compact navigation heler. .toc-light is gray color. By removing this class we make it default black which marks current position in the document.
.toc-mini {
  display: flex;
  flex-direction: column;
  font-size: 6pt;
  cursor: pointer;
}

.toc-light {
  color: lightgray;
}
Generating HTML in vanilla JavaScript is not great, but it works for small things:
function genTocMini(items) {
  let tmp = "";
  let t = `<div class="toc-item-mini toc-light">▃</div>`;
  for (let i = 0; i < items.length; i++) {
    tmp += t;
  }
  return `<div class="toc-mini">` + tmp + `</div>`;
}
items is an array of TocItem we get from buildTocItems(). We mark the items with toc-item-mini class so that we can query them all easily.

Generate toc list

Table of contents list is only slightly more complicated:
<div class="toc-list">
  <div title="{title}" class="toc-item toc-trunc {ind}" onclick=tocGoTo({n})>{text}</div>
  ... repeat for every TocItem
</div>
{ind} is name of the indent class, like:
.toc-ind-1 {
  padding-left: 4px;
}
tocGoTo(n) is a function that scroll the page to show n-th TocItem.element at the top.
function genTocList(items) {
  let tmp = "";
  let t = `<div title="{title}" class="toc-item toc-trunc {ind}" onclick=tocGoTo({n})>{text}</div>`;
  let n = 0;
  for (let h of items) {
    let s = t;
    s = s.replace("{n}", n);
    let ind = "toc-ind-" + h.nesting;
    s = s.replace("{ind}", ind);
    s = s.replace("{text}", h.text);
    s = s.replace("{title}", h.text);
    tmp += s;
    n++;
  }
  return `<div class="toc-list">` + tmp + `</div>`;
}
.toc-trunc is for limiting the width of toc and gracefully truncating it:
.toc-trunc {
  max-width: 32ch;
  min-width: 12ch;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

Putting it all together

Here’s the code that runs at page load, generates HTML and appends it to the page:
function genToc() {
  tocItems = buildTocItems();
  fixNesting(tocItems);
  const container = document.createElement("div");
  container.className = "toc-wrapper";
  let s = genTocMini(tocItems);
  let s2 = genTocList(tocItems);
  container.innerHTML = s + s2;
  document.body.appendChild(container);
}
Showing / hiding toc list is done in CSS. When user clicks toc item, we need to show it at the top of page:
let tocItems = [];
function tocGoTo(n) {
  let el = tocItems[n].element;
  let y = el.getBoundingClientRect().top + window.scrollY;
  let offY = 12;
  y -= offY;
  window.scrollTo({
    top: y,
  });
}
We remembered HTML element in TocItem.element so all we need to do is to scroll to it to show it at the top of page.
You can adjust offY e.g. if you show a navigation bar at the top that overlays the content, you want to make offY at least the height of navigation bar.

Highlight during navigation

We also want to highlight the clicked element. We do it by temporarily changing the background to yellow:
/**
 * @param {HTMLElement} el
 */
function highlightElement(el) {
  let tempBgColor = "yellow";
  let origCol = el.style.backgroundColor;
  if (origCol === tempBgColor) {
    return;
  }
  el.style.backgroundColor = tempBgColor;
  setTimeout(() => {
    el.style.backgroundColor = origCol;
  }, 1000);
}
To make it more nicer, we’ll use CSS transition to fade the color in and out:
h1, h2, h3, h4, h5, h6 {
  transition: background-color 1s ease;
}

Show current position in page

When user scrolls the page we want to reflect that in toc mini by changing the color of corresponding rectangle from gray to black and in toc list by using bold font.
On scroll event we calculate which visible TocItem.element is closest to the top of window and set desired CSS class on toc item corresponding to that element:
function showSelectedTocItem(elIdx) {
  // make toc-mini-item black for closest element
  let els = document.querySelectorAll(".toc-item-mini");
  let cls = "toc-light";
  for (let i = 0; i < els.length; i++) {
    let el = els[i];
    if (i == elIdx) {
      el.classList.remove(cls);
    } else {
      el.classList.add(cls);
    }
  }

  // make toc-item bold for closest element
  els = document.querySelectorAll(".toc-item");
  cls = "toc-bold";
  for (let i = 0; i < els.length; i++) {
    let el = els[i];
    if (i == elIdx) {
      el.classList.add(cls);
    } else {
      el.classList.remove(cls);
    }
  }
}

function updateClosestToc() {
  let closestIdx = -1;
  let closestDistance = Infinity;

  for (let i = 0; i < tocItems.length; i++) {
    let tocItem = tocItems[i];
    const rect = tocItem.element.getBoundingClientRect();
    const distanceFromTop = Math.abs(rect.top);
    if (
      distanceFromTop < closestDistance &&
      rect.bottom > 0 &&
      rect.top < window.innerHeight
    ) {
      closestDistance = distanceFromTop;
      closestIdx = i;
    }
  }
  if (closestIdx >= 0) {
    showSelectedTocItem(closestIdx);
  }
}

window.addEventListener("scroll", updateClosestToc);

All together now

After page loads I run:
genToc();
updateClosestToc();
Which I achieve by including this in HTML:
  <script src="/help.js" defer></script>
  </body>

Possible improvements

Software is never finished. Software can always be improved.
I have 2 ideas for further improvements.

Always visible when enough space

Most of the time my browser window uses half of 13 to 15 inch screen. I’m aggravated when websites don’t work well at that size.
At that size there’s not enough space to always show toc. But if the user chooses a wider browser window, it makes sense to use available space and always show table of contents.

Keyboard navigation

It would be nice to navigate table of contents with keyboard, in addition to mouse.
For example:

The code

Here’s full code.
programming javascript webdev
Aug 24 2024

Feedback about page:

Feedback:
Optional: your email if you want me to get back to you: