Bram Van Damme recently shared a really cool method of showing a table of contents on a page and I’ve wanted to implement it on this blog ever since.
I got some free hours to play around with it this week, and if all went to plan you should see a live version at the right-hand side. It’s only made visible for screens whose width is larger than 1024px.
Since most readers here are technical, that should apply to everyone that reads this on a modern desktop machine.
What’s the sticky table of contents?
If you’re reading this on a mobile device you won’t see any change. So here’s a quick animation of what that table of contents is like.
A smooth-scrolling, always available page index for lengthy blogpost. Pretty neat, right?
Adding a table of contents in Hugo
Hugo has a short-code for rendering a table of contents. You can get it in every page by adding {{ .TableOfContents }}
in your page templates.
It parses your Markdown files and extracts the headers as list-items. It just lacks styling, which is what we’ll do here.
Because I’m using conditional formatting (only showing the ToC on large screens), I updated my theme template files too. I’ll only include the ToC code if there is something to show.
{{ if gt (len .TableOfContents) 80}}
<!-- Version of the page with table of contents -->
<main>
<div id="top-of-site-pixel-anchor"></div>
<div class="content blog-content">
{{ .Content }}
</div>
<aside class="hidden lg:block tableOfContentContainer" id="tableOfContentContainer">
<h3>Table of contents</h3>
{{ .TableOfContents }}
</aside>
</main>
{{ else }}
<!-- Version of the page without table of contents -->
<div class="content blog-content">
{{ .Content }}
</div>
{{ end }}
This snippet will make sure the ToC HTML only gets added when needed. It allows me to get a custom layout in place for those pages that do have a ToC.
The pages without page headings will render as normal, as they did before.
Styling the table of contents
There’s no need for me to copy/paste my CSS here, you can just look at the source code. Nearly all of my code is straight from Bram’s excellent blogpost, that takes you by the hand every step of the way.
The secret sauce is in the grid-template-columns: 100% 15em;
and position: sticky; align-self: start;
part.
I did change the JavaScript code a bit for styling links when they get into view. Some of my headers are very close together or very far apart on a page. It had the effect that, sometimes, multiple items in the table of contents would get the active
class applied to them.
Or when page headers are too far apart, they’d go out of view and no item would be marked as active
.
I added a simple function that removes the active
class from all items first, before setting a new one.
function clearActiveStatesInTableOfContents() {
document.querySelectorAll('aside nav li').forEach((section) => {
section.classList.remove('active');
});
}
(Seriously, modern JavaScript is powerful!)
That bloody fixed header navigation
Update: Bram gave me the CSS fix I needed, no more hacky JavaScript 🥳
This used to be very complicated with tracking div’s and setting observers in JavaScript to alter the location of that div, but it’s all very clean CSS now!
Offsetting the anchor links from the top
Another issue I had to tackle with that fixed navigation, is that using anchor links on a page makes the scrolling a bit off.
When you link to an anchor element like <a href="#header-1">
, the page doesn’t reload but instead you scroll to the element with id header-1
. By default, that header will be behind the top navigation.
To add the necessary offset for this type of anchor-linking, I added the following CSS to the page.
html, body {
scroll-padding-top: 100px;
}
There are a lot of complicated solutions out there, but this seems to be the simplest one and is widely adopted in modern browsers.
Step by step!
I’m still no expert at CSS, but I am enjoying taming the layout engine to do what I want.
Since I also don’t care about IE (or Edge for that matter) and can just focus on Chrome & Firefox, it also feels like browser compatibility isn’t a problem anymore.
Or maybe I haven’t run into it just yet.