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.
A good toc
A good table of contents is:
- always available
- unobtrusive
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 1
… 6
for h1
… h6
. 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:
.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 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);
}
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 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:
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
The code