Hugo + js = highlighted table of contents

Hugo + js = highlighted table of contents

Example on how to highlight Hugo’s default generated table of contents. While Hugo provides a default TOC (table of contents) which you can include on your page it does not have many features, like highlighting. Fortunately it can easily be added. From a UX point of view highlighting is useful to orientate the user on a page, especially if you have long form content. It can indicate where the user currently is, what has been read already and what is currently visible.

At the time of writing the layout of this website only shows the table of contents for non-mobile screens (desktop/tablet).

This article is a (heavy) modified version of the TOC highlighting implemented by mkdocs-material, all credit goes to Martin Donath.

This article was written for Hugo 0.70.

Code

The code is fairly straight forward, from the DOM fetch all TOC entries (as generated by Hugo) and compare their page offset to the current viewport (visible area). Every time the user scrolls, or the screen resizes, update the highlight state of the TOC entries to indicate which entries are no longer visible, currently visible and not yet visible. The state of the TOC is cached so we only have to adjust all elements above/below the current scroll position, for performance reasons. It also contains a minor work-around for the UI which is probably not applicable for your layout and can be left out.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
class HighlightTOC {

  constructor(tocElement) {
    this._tocElement = tocElement;

    this._index = 0;
    this._offset = window.pageYOffset;
    this._scrollDirection = false; // = Up/down -> down
    this._updateTimeout = null;

    this._elements = tocElement.querySelectorAll("a");
    this._anchors = [].reduce.call(
      this._elements,
      (anchors, el) => {
        const hash = decodeURIComponent(el.hash);
        return anchors.concat(document.getElementById(hash.substring(1)) || []);
      },
      []
    );

    // TODO work-around: ideally this would be done in HTML/CSS/Hugo but it is easier to do in JS
    if (this._elements.length === 0) {
      tocElement.parentNode.children[0].style.display = "none";
      document.querySelectorAll(".mobile-toc-icon").forEach(e => { e.style.display = "none" }); // TODO hardcoded name
      document.querySelectorAll(".side-control").forEach((e) => (e.style.display = "none")); // TODO hardcoded name
      document.getElementById("side-control").checked = false; // TODO hardcoded name
      tocElement.style.display = "none";
      return;
    }

    var tocWrapper = document.querySelector(".content-side"); // TODO hard coded name
    this._isElementVisible = tocWrapper.clientHeight !== 0 && tocWrapper.clientWidth !== 0;

    // Wire events
    window.addEventListener("scroll", () => {
      clearTimeout(this._updateTimeout); // Prevents the previous task from executing

      this._updateTimeout = window.setTimeout(() => { this._update(); }, 100); // Delay is required because some websites have a scroll delay which would cause _update to block scrolling. Effectively break the TOC links.
    });
    window.addEventListener("resize", () => { this._resize(); });
    new ResizeObserver(elems => { this._visibilityChanged(elems[0]); }).observe(tocWrapper); // Ideally we could detect this from the TOC element itself

    this._update();
  }

  _update() {
    // No need to waste CPU cycles; alternatively we could unsubscribe
    if (!this._isElementVisible) {
      return;
    }

    // offset = scroll position + height of the viewport
    const offset = (window.pageYOffset || document.documentElement.scrollTop) + (window.innerHeight || document.documentElement.clientHeight);
    const direction = this._offset - offset < 0;

    // Reset highlight
    this._elements[this._index].dataset.current = "";

    // Hack: reset index if direction changed to catch very fast scrolling, because otherwise we would have to register a timer and that sucks
    if (this._scrollDirection !== direction) this._index = direction ? (this._index = 0) : (this._index = this._elements.length - 1);

    // Scroll direction is down
    if (this._offset <= offset) {
      for (let i = this._index + 1; i < this._elements.length; i++) {
        if (this._anchors[i].offsetTop <= offset) {
          if (i > 0) this._elements[i - 1].dataset.state = "visited";
          this._index = i;
        } else {
          break;
        }
      }
    } else {
      //  Scroll direction is up
      for (let i = this._index; i >= 0; i--) {
        if (this._anchors[i].offsetTop > offset) {
          if (i > 0) this._elements[i - 1].dataset.state = "";
        } else {
          this._index = i;
          break;
        }
      }
    }

    // Highlight current
    // Optional: we could highlight/active all currently visible TOC entries instead of only the last one.
    const currentElement = this._elements[this._index];
    currentElement.dataset.current = "active";
    currentElement.scrollIntoView(false);

    this._offset = offset;
    this._scrollDirection = direction;
  }

  _visibilityChanged(elem) {
    const wasVisible = this._isElementVisible;

    this._isElementVisible = elem.contentRect.inlineSize !== 0 && elem.contentRect.blockSize !== 0;
    if (wasVisible !== this._isElementVisible) {
      if (this._isElementVisible) {
        this._update();
      } else {
        this._reset();
      }
    }
  }

  _resize() {
    this._reset();
    this._update();
  }

  _reset() {
    Array.prototype.forEach.call(this._elements, (el) => {
      el.dataset.state = "";
      el.dataset.current = "";
    });

    this._index = 0;
    this._offset = 0;
    this._scrollDirection = false;
  }
}

// ==========================================================================

window.addEventListener("load", function () {
  const tocElement = document.getElementById("TableOfContents"); // TOC element created by Hugo
  if (tocElement) { // Not all pages contain a TOC
    new HighlightTOC(tocElement);
  }
});

The HTML is provided by Hugo so we only need SCSS to style the visited and current active entry(s) in the TOC.

#TableOfContents {
    [data-state="visited"] {
        color: var(--font-color-muted);
    }

    [data-current="active"] {
        text-decoration: underline;
    }
}

Bonus, numbered headings

Taken from the hugo-book theme, all credit goes to Alex Shpak.

If you want to number the heading (both in the TOC and content) you can use this SCSS snippet.

Note that in the code, as personal preference, it only numbers up to a depth of 3 and .markdown is the parent of the article’s content. You will likely have to adjust the code to your layout.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
.content-page .markdown {
    @for $h from 1 through 3 {
  
      // Only number those that are in the TOC
      >h#{$h} {
        counter-increment: h#{$h};
        counter-reset: h#{$h + 1};
  
        $content: "";
  
        @for $n from 1 through $h {
          $content: $content+'counter(h#{$n})"."';
        }
  
        &::before {
          content: unquote($content) " ";
        }
      }
    }
  }
  
  #TableOfContents ul {
    list-style: decimal;
  }

Demo

The following is some mock content for demo purposes. Note that the TOC depth is limited to 3, this can be configured in Hugo’s config file.

Commodo nulla

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Tellus pellentesque eu tincidunt tortor aliquam nulla facilisi. Leo urna molestie at elementum eu facilisis. Velit euismod in pellentesque massa placerat duis. Neque sodales ut etiam sit amet nisl. Phasellus vestibulum lorem sed risus ultricies. Consectetur adipiscing elit ut aliquam purus. Tellus in hac habitasse platea dictumst vestibulum rhoncus est. A iaculis at erat pellentesque adipiscing commodo elit at. Nunc mattis enim ut tellus elementum sagittis vitae et leo. Proin sed libero enim sed faucibus.

Facilisis mauris sit amet massa vitae. Amet nisl purus in mollis nunc sed. Fusce ut placerat orci nulla pellentesque dignissim enim. Id interdum velit laoreet id donec ultrices tincidunt arcu. Pharetra et ultrices neque ornare aenean euismod. Ut eu sem integer vitae. Ligula ullamcorper malesuada proin libero nunc consequat interdum. Quis varius quam quisque id. Lacus sed viverra tellus in. Vitae sapien pellentesque habitant morbi tristique senectus et. Feugiat nibh sed pulvinar proin gravida hendrerit. Duis at tellus at urna condimentum mattis pellentesque id. Ultrices vitae auctor eu augue ut lectus. Scelerisque purus semper eget duis at tellus at urna condimentum. Morbi quis commodo odio aenean sed adipiscing diam. Urna neque viverra justo nec ultrices dui sapien. Quis blandit turpis cursus in hac habitasse platea dictumst quisque. Tellus id interdum velit laoreet id donec ultrices. Dui accumsan sit amet nulla facilisi.

Nullam vehicula ipsum a arcu cursus. Nibh sit amet commodo nulla facilisi nullam vehicula ipsum. Cursus in hac habitasse platea. Pulvinar elementum integer enim neque volutpat ac tincidunt vitae. Ultricies integer quis auctor elit sed vulputate. Varius morbi enim nunc faucibus a. Suscipit tellus mauris a diam maecenas sed enim. Mauris commodo quis imperdiet massa tincidunt nunc pulvinar sapien. Vulputate enim nulla aliquet porttitor. Tristique et egestas quis ipsum suspendisse ultrices gravida dictum fusce. Laoreet id donec ultrices tincidunt arcu non sodales. Fames ac turpis egestas sed tempus. Tellus pellentesque eu tincidunt tortor aliquam nulla facilisi cras. Sapien nec sagittis aliquam malesuada bibendum arcu vitae elementum curabitur. Tincidunt eget nullam non nisi est sit. Sit amet facilisis magna etiam.

Scelerisque eu ultrices vitae auctor eu augue ut lectus. Risus quis varius quam quisque. Viverra tellus in hac habitasse. Egestas tellus rutrum tellus pellentesque eu tincidunt tortor aliquam nulla. Amet volutpat consequat mauris nunc. Neque vitae tempus quam pellentesque nec. Egestas integer eget aliquet nibh praesent tristique magna. Tortor dignissim convallis aenean et tortor at risus viverra adipiscing. Integer feugiat scelerisque varius morbi enim nunc faucibus a. Maecenas accumsan lacus vel facilisis volutpat est velit. Non arcu risus quis varius. Suspendisse faucibus interdum posuere lorem ipsum dolor sit amet. Lacus sed viverra tellus in hac habitasse. Nunc consequat interdum varius sit amet mattis vulputate enim nulla. Eget est lorem ipsum dolor. Sapien eget mi proin sed libero.

Arcu cursus euismod quis viverra nibh. Lacus sed viverra tellus in hac. Tellus mauris a diam maecenas sed enim. Neque vitae tempus quam pellentesque nec nam aliquam. Nulla facilisi cras fermentum odio. Vulputate odio ut enim blandit volutpat maecenas volutpat. Tellus elementum sagittis vitae et leo duis ut. Odio pellentesque diam volutpat commodo. Fringilla urna porttitor rhoncus dolor purus non enim praesent. Gravida rutrum quisque non tellus. Ullamcorper malesuada proin libero nunc consequat interdum varius sit amet. Diam vel quam elementum pulvinar etiam non. Nunc pulvinar sapien et ligula ullamcorper malesuada proin. Lectus nulla at volutpat diam. Eget dolor morbi non arcu risus. Ut lectus arcu bibendum at varius. Eget nulla facilisi etiam dignissim diam quis. Massa tincidunt nunc pulvinar sapien et ligula ullamcorper malesuada proin. Molestie nunc non blandit massa enim nec dui nunc mattis. A cras semper auctor neque vitae tempus quam pellentesque nec.

Urna porttitor

Cras tincidunt lobortis feugiat vivamus at augue eget arcu dictum. Semper feugiat nibh sed pulvinar proin. Malesuada pellentesque elit eget gravida cum sociis natoque. Risus quis varius quam quisque id diam vel quam. Nibh ipsum consequat nisl vel pretium lectus. Tellus id interdum velit laoreet id donec ultrices tincidunt. Fermentum posuere urna nec tincidunt praesent semper feugiat nibh. Aliquam vestibulum morbi blandit cursus risus. Amet luctus venenatis lectus magna fringilla urna porttitor. Tristique nulla aliquet enim tortor at auctor. In arcu cursus euismod quis viverra nibh cras pulvinar mattis. Purus viverra accumsan in nisl nisi scelerisque eu ultrices vitae. Pharetra diam sit amet nisl suscipit adipiscing. Nulla porttitor massa id neque aliquam vestibulum morbi blandit cursus. Scelerisque felis imperdiet proin fermentum leo vel orci porta. Etiam erat velit scelerisque in dictum non consectetur.

Tortor at auctor

Amet purus gravida quis blandit turpis cursus in hac habitasse. Gravida cum sociis natoque penatibus et magnis dis. A diam maecenas sed enim ut sem. Non enim praesent elementum facilisis leo vel. Nibh venenatis cras sed felis eget. Pharetra magna ac placerat vestibulum. In pellentesque massa placerat duis ultricies lacus sed. Vitae tempus quam pellentesque nec nam. Fermentum iaculis eu non diam phasellus vestibulum lorem sed. Ipsum faucibus vitae aliquet nec ullamcorper sit amet. Lorem ipsum dolor sit amet consectetur adipiscing elit duis tristique. Felis bibendum ut tristique et egestas quis ipsum. Dolor sit amet consectetur adipiscing elit duis tristique sollicitudin. Sit amet consectetur adipiscing elit. Ac turpis egestas maecenas pharetra convallis posuere.

Fringilla phasellu

Duis at tellus at urna condimentum mattis pellentesque. Sed blandit libero volutpat sed cras ornare arcu dui. Adipiscing vitae proin sagittis nisl rhoncus. Egestas purus viverra accumsan in nisl nisi scelerisque eu. Nibh sed pulvinar proin gravida hendrerit. Ut pharetra sit amet aliquam id diam maecenas ultricies. Vitae tortor condimentum lacinia quis vel eros donec ac. Congue quisque egestas diam in arcu cursus euismod quis. Pellentesque diam volutpat commodo sed egestas egestas fringilla phasellus. Mauris nunc congue nisi vitae suscipit tellus mauris a.

Tristique senectus

Tellus rutrum tellus pellentesque eu tincidunt tortor aliquam nulla. Tortor at risus viverra adipiscing at in tellus integer. Tortor at auctor urna nunc. Accumsan in nisl nisi scelerisque eu. Mi eget mauris pharetra et ultrices neque ornare. Commodo ullamcorper a lacus vestibulum sed arcu. Aliquet bibendum enim facilisis gravida. Curabitur vitae nunc sed velit dignissim. Pulvinar etiam non quam lacus suspendisse faucibus interdum posuere. Euismod quis viverra nibh cras pulvinar. Eu facilisis sed odio morbi quis commodo odio aenean sed. Pharetra magna ac placerat vestibulum lectus mauris ultrices eros. Vulputate eu scelerisque felis imperdiet proin fermentum. Amet justo donec enim diam vulputate ut pharetra sit amet. Amet venenatis urna cursus eget nunc. Habitasse platea dictumst quisque sagittis purus. Odio ut enim blandit volutpat maecenas volutpat blandit aliquam. Vulputate ut pharetra sit amet.

Odio morbi

Ultricies lacus sed turpis tincidunt id aliquet risus feugiat in. Nisl nisi scelerisque eu ultrices vitae. Sit amet justo donec enim diam. Egestas quis ipsum suspendisse ultrices gravida dictum. Tristique senectus et netus et. Consectetur libero id faucibus nisl tincidunt eget nullam non nisi. Scelerisque eu ultrices vitae auctor eu. Sit amet dictum sit amet justo. Fames ac turpis egestas integer eget aliquet nibh praesent tristique. Et netus et malesuada fames ac turpis egestas integer eget. Fames ac turpis egestas sed tempus urna.

Noticed an error in this post? Corrections are appreciated.

© Nelis Oostens