Adding a sticky table of contents in Hugo to posts

Profile image of Mattias Geniar

Mattias Geniar, February 26, 2020

Follow me on Twitter as @mattiasgeniar

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 -->
        <div id="top-of-site-pixel-anchor"></div>

        <div class="content blog-content">
            {{ .Content }}

        <aside class="hidden lg:block tableOfContentContainer" id="tableOfContentContainer">

            <h3>Table of contents</h3>
            {{ .TableOfContents }}

{{ else }}

    <!-- Version of the page without table of contents -->
    <div class="content blog-content">
        {{ .Content }}

{{ 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) => {

(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!

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.

Want to subscribe to the cron.weekly newsletter?

I write a weekly-ish newsletter on Linux, open source & webdevelopment called cron.weekly.

It features the latest news, guides & tutorials and new open source projects. You can sign up via email below.

No spam. Just some good, practical Linux & open source content.