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:

A good table of contents
A good table of contents is:
- always available
- unobtrusive
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 1
… 6
for h1
… h6
. 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:
.toc-mini
, always visible, shows overview of the toc
.toc-list
hidden by default, shown on hover over .toc-wrapper
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);
}
Navigating
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:
t
would show table of contents
Esc
would dismiss it
up
/ down
arrows would navigate toc tree
Enter
would navigate to selected part and dismiss toc