Home
Notion-like compact table of contents in JavaScript
Large web pages, especially documentation, benefit from having a table of contents for navigating within document.
This article describes how I implemented a compact table of contents for documentation page for my Edna note taking application as well as for this very blog.
I was inspired by Notion. It seems that others, like Substack, also were inspired by them.
Here’s the compact version:
Here’s full version shown when you hover mouse over compact version:
Took only few hours and you can see the full code at https://gist.github.com/kjk/d9343c3f45d9f529b2b8156048254840

A good table of contents

A good table of contents is:
Full table of contents cannot be always visible. Space is at premium and should be used for the main text of the content.
A compact for of table of contents should always be visible.
I noticed that Notion implemented such an idea in a nice way. Since great artists steal, I decided to implement similar behavior for Edna’s documentation
In compact view toc is just a column of small, gray rectangles. Each rectangle represents a toc entry. If rectangle is black, it represent currently visible entry.
That gives user an indication where he is withing the document when scrolling.
It’s small and unobtrusive, positioned either to the left or right. It can be anchored to the top or centered vertically.
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;
}
We show text to the user. hLevel is 16 for h1h6. nesting is like hLevel but sanitized. We use it to indent text in toc, to visualize the tree structure.
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 because we want it to be compact. .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>`;
}

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 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.

Updating toc mini to reflect current position

When user scrolls the page we want to reflect that in toc mini by changing the color of corresponding rectangle from gray to black.
On scroll event we calculate which visible TocItem.element is closest to the top of window.
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) {
    console.log("Closest element:", closestIdx);
    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 == closestIdx) {
        el.classList.remove(cls);
      } else {
        el.classList.add(cls);
      }
    }
  }
}

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:
webdev javascript edna
Jun 27 2025

Feedback about page:

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