Tables of content

Overview

Insert a table of contents (TOC) into pages on your Hugo site using one of four methods:

  1. Use the .Page.TableOfContents method
  2. Use the .Page.Fragments.ToHTML method
  3. Build your own by recursively walking .Page.Fragments.Headings on a page
  4. Build your own by parsing the content after Hugo has rendered the page

Each method has advantages and disadvantages. See the feature and performance comparisons at the end of this article.

Method 1: TableOfContents

The simplest approach is to use .Page.TableOfContents method in a template, partial, or shortcode. For example, in a single page template:

<h1>{{ .Title }}</h1>
{{ .TableOfContents }}
{{ .Content }}

Method 2: Fragments to HTML

In a URL, whether absolute or relative, the fragment links to an id attribute of an HTML element on the page.

/articles/article-1#section-2
------------------- ---------
       path         fragment

Hugo assigns an id attribute to each heading on a page, which you can override with Markdown attributes as needed. This creates the relationship between an entry in the TOC and a heading on the page.

Hugo introduced .Page.Fragments in v0.111.0. This structure provides the following methods:

.Headings
(map) A nested map of all headings on the page. Each map contains the following keys: ID, Level, Title and Headings.
.HeadingsMap
(slice) A slice of maps of all headings on the page, with first-level keys for each heading. Each map contains the following keys: ID, Level, Title and Headings.
.Identifiers
(slice) A slice containing the id of each heading on the page.
.Identifiers.Contains
(bool) Returns true if one or more headings on the page has the given id, useful for validating fragments within a link render hook.
.Identifiers.Count
(int) The number of headings on a page with the given id attribute, useful for detecting duplicates.
.ToHTML
(string) Returns a TOC as a nested list, either ordered or unordered, identical to the HTML returned by .Page.TableOfContents. This method take three arguments: the start level (int), the end level (int), and a boolean (true to return an ordered list, false to return an unordered list).

To use the .Identifiers.Contains or .Identifiers.Count methods:

{{ .Fragments.Identifiers.Contains "section-2" }}
{{ .Fragments.Identifiers.Count "section-2" }}

To examine the .Fragments data structure on a page, place this in a template:

<pre>{{ jsonify (dict "indent" "  ") .Fragments }}</pre>

To build a TOC using the .ToHTML method:

{{ $startLevel := 2 }}
{{ $endLevel := 3 }}
{{ $ordered := true }}
{{ .Fragments.ToHTML $startLevel $endLevel $ordered | safeHTML }}

This detailed example allows you to:

  • Set start and end levels in site configuration and/or front matter
  • Define a threshold to display the TOC based on the number of headings that would appear in the TOC, set in site configuration and/or front matter
  • Detect duplicate heading ids
layouts/partials/toc-fragments-to-html.html
{{- /* Last modified: 2024-05-07T07:11:34-07:00 */}}

{{- /*
Copyright 2023 Veriphor LLC

Licensed under the Apache License, Version 2.0 (the "License"); you may not
use this file except in compliance with the License. You may obtain a copy of
the License at

https://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations under
the License.
*/}}

{{- /*
Renders a table of contents from .Page.Fragments.ToHTML.

In site configuration, set the default start level, end level, and the minimum
number of headings required to show the table of contents:

    [params.toc]
    startLevel = 2      # default is 2
    endLevel = 3        # default is 3
    minNumHeadings = 2  # default is 2

To display the table of contents on a page:

    +++
    title = 'Post 1'
    toc = true
    +++

To display the table of contents on a page, and override one or more of the
default settings:

    +++
    title = 'Post 1'
    [toc]
    startLevel = 2      # default is 2
    endLevel = 3        # default is 3
    minNumHeadings = 2  # default is 2
    +++

Start with these basic CSS rules to style the table of contents:

    .toc li {
      list-style-type: none;
    }
    .toc ol {
      padding: 0 0 0 1em;
    }
    .toc > ol {
      padding-left: 0;
    }

@context {page} .

@returns {template.HTML}

@example {{ partial "toc-fragments-to-html.html" . }}
*/}}

{{- /* Initialize. */}}
{{- $partialName := "toc-fragments-to-html" }}

{{- /* Verify minimum required version. */}}
{{- $minHugoVersion := "0.125.6" }}
{{- if lt hugo.Version $minHugoVersion }}
  {{- errorf "The %q partial requires Hugo v%s or later." $partialName $minHugoVersion }}
{{- end }}

{{- /* Determine content path for warning and error messages. */}}
{{- $contentPath := "" }}
{{- with .File }}
  {{- $contentPath = .Path }}
{{- else }}
  {{- $contentPath = .Path }}
{{- end }}

{{- /* Check for duplicate heading IDs. */}}
{{- $duplicateIDs := slice }}
{{- range .Fragments.Identifiers }}
  {{- if gt ($.Fragments.Identifiers.Count .) 1 }}
    {{- $duplicateIDs = $duplicateIDs | append . }}
  {{- end }}
{{- end }}
{{- with $duplicateIDs | uniq }}
  {{- errorf "The %q partial detected duplicate heading IDs (%s) in %s" $partialName (delimit . ", ") $contentPath }}
{{- end }}

{{- /* Render. */}}
{{- if .Params.toc }}
  {{- $startLevel := or (.Param "toc.startLevel" | int) 2 }}
  {{- $endLevel := or (.Param "toc.endLevel" | int) 3 }}
  {{- $minNumHeadings := or (.Param "toc.minNumHeadings" | int) 2 }}
  {{- $toc := .Fragments.ToHTML $startLevel $endLevel true | safeHTML }}
  {{- $numHeadings := $toc | findRE `<a href=".+">.+</a>` | len }}
  {{- if ge $numHeadings $minNumHeadings }}
    {{- $toc }}
  {{- end }}
{{- end }}

To use this in a template or shortcode:

{{ partial "toc-fragments-to-html.html" . }}

Method 3: Walk headings

Build a TOC by recursively walking .Page.Fragments.Headings, using .ID, Level, and .Title to create each entry.

This detailed example allows you to:

  • Set start and end levels in site configuration and/or front matter
  • Define a threshold to display the TOC based on the number of headings that would appear in the TOC, set in site configuration and/or front matter
  • Detect duplicate and missing heading ids
  • Customize HTML elements and attributes
  • Create site-relative instead of page-relative link
layouts/partials/toc-walk-headings.html
{{- /* Last modified: 2024-05-07T07:11:34-07:00 */}}

{{- /*
Copyright 2023 Veriphor LLC

Licensed under the Apache License, Version 2.0 (the "License"); you may not
use this file except in compliance with the License. You may obtain a copy of
the License at

https://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations under
the License.
*/}}

{{- /*
Renders a table of contents by walking .Page.Fragments.Headings.

In site configuration, set the default start level, end level, and the minimum
number of headings required to show the table of contents:

    [params.toc]
    startLevel = 2      # default is 2
    endLevel = 3        # default is 3
    minNumHeadings = 2  # default is 2

To display the table of contents on a page:

    +++
    title = 'Post 1'
    toc = true
    +++

To display the table of contents on a page, and override one or more of the
default settings:

    +++
    title = 'Post 1'
    [toc]
    startLevel = 2      # default is 2
    endLevel = 3        # default is 3
    minNumHeadings = 2  # default is 2
    +++

Change or localize the title with a "toc_title" key in your i18n file(s).

Start with these basic CSS rules to style the table of contents:

    .toc li {
      list-style-type: none;
    }
    .toc ol {
      padding: 0 0 0 1em;
    }
    .toc > ol {
      padding-left: 0;
    }
    .toc-title {
      font-weight: bold;
    }

@context {page} .

@returns {template.HTML}

@example {{ partial "toc-walk-headings.html" . }}
*/}}

{{- /* Initialize. */}}
{{- $partialName := "toc-walk-headings" }}

{{- /* Verify minimum required version. */}}
{{- $minHugoVersion := "0.125.6" }}
{{- if lt hugo.Version $minHugoVersion }}
  {{- errorf "The %q partial requires Hugo v%s or later." $partialName $minHugoVersion }}
{{- end }}

{{- /* Determine content path for warning and error messages. */}}
{{- $contentPath := "" }}
{{- with .File }}
  {{- $contentPath = .Path }}
{{- else }}
  {{- $contentPath = .Path }}
{{- end }}

{{- /* Check for duplicate heading IDs. */}}
{{- $duplicateIDs := slice }}
{{- range .Fragments.Identifiers }}
  {{- if gt ($.Fragments.Identifiers.Count .) 1 }}
    {{- $duplicateIDs = $duplicateIDs | append . }}
  {{- end }}
{{- end }}
{{- with $duplicateIDs | uniq }}
  {{- errorf "The %q partial detected duplicate heading IDs (%s) in %s" $partialName (delimit . ", ") $contentPath }}
{{- end }}

{{- /* Render. */}}
{{- if .Params.toc }}
  {{- with .Fragments.Headings }}
    {{- $startLevel := or ($.Param "toc.startLevel" | int) 2 }}
    {{- $endLevel := or ($.Param "toc.endLevel" | int) 3 }}
    {{- $numHeadings := where (sort $.Fragments.HeadingsMap) "Level" "in" (seq $startLevel $endLevel) | len }}
    {{- if ge $numHeadings (or ($.Param "toc.minNumHeadings" | int) 2) }}
      <nav class="toc">
        <div class="toc-title">
          {{ or (T "toc_title") "Table of contents" | safeHTML }}
        </div>
        <ol>
          {{- $ctx := dict
            "page" $
            "contentPath" $contentPath
            "partialName" $partialName
            "startLevel" $startLevel
            "endLevel" $endLevel
            "headings" .
          }}
          {{- partial "inline/toc/walk.html" $ctx }}
        </ol>
      </nav>
    {{- end }}
  {{- end }}
{{- end }}

{{- /* Recursively walk the headings. */}}
{{- define "partials/inline/toc/walk.html" }}
  {{- $ctx := . }}
  {{- range $ctx.headings }}
    {{- if and (ge .Level $ctx.startLevel) (le .Level $ctx.endLevel) }}
      <li>
        {{- if not .ID }}
          {{- errorf "The %q partial detected that the %q heading has an empty ID attribute. See %s" $ctx.partialName .Title $ctx.contentPath }}
        {{- end }}
        {{- $href := printf "%s#%s" $ctx.page.RelPermalink .ID }}
        <a href="{{ $href }}">{{ .Title | plainify | safeHTML }}</a>
        {{- with and (lt .Level $ctx.endLevel) .Headings }}
          <ol>
            {{- $ctx = merge $ctx (dict "headings" .) }}
            {{- partial "inline/toc/walk.html" $ctx }}
          </ol>
        {{- end }}
      </li>
    {{- else }}
      {{- $ctx = merge $ctx (dict "headings" .Headings) }}
      {{- partial "inline/toc/walk.html" $ctx }}
    {{- end }}
  {{- end }}
{{- end }}

To use this in a template or shortcode:

{{ partial "toc-walk-headings.html" . }}

Method 4: Parse content

This approach uses regular expressions to parse the page content after Hugo has rendered the page. This allows you to capture headings generated by deeply nested shortcodes1 regardless of the calling notation, {{< >}} or {{% %}}.

You cannot call this partial from a shortcode. The content we need to parse includes the call to the shortcode—an infinite loop.

layouts/partials/toc-parse-content.html
{{- /* Last modified: 2024-05-07T07:11:34-07:00 */}}

{{- /*
Copyright 2023 Veriphor LLC

Licensed under the Apache License, Version 2.0 (the "License"); you may not
use this file except in compliance with the License. You may obtain a copy of
the License at

https://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations under
the License.
*/}}

{{- /*
Renders a table of contents by parsing rendered content.

In site configuration, set the default start level, end level, and the minimum
number of headings required to show the table of contents:

    [params.toc]
    startLevel = 2      # default is 2
    endLevel = 3        # default is 3
    minNumHeadings = 2  # default is 2

To display the table of contents on a page:

    +++
    title = 'Post 1'
    toc = true
    +++

To display the table of contents on a page, and override one or more of the
default settings:

    +++
    title = 'Post 1'
    [toc]
    startLevel = 2      # default is 2
    endLevel = 3        # default is 3
    minNumHeadings = 2  # default is 2
    +++

Change or localize the title with a "toc_title" key in your i18n file(s).

Start with these basic CSS rules to style the table of contents:

    a.toc-item {
      display: block;
    }
    a.toc-level-1 {
      margin-left: 0em;
    }
    a.toc-level-2 {
      margin-left: 1em;
    }
    a.toc-level-3 {
      margin-left: 2em;
    }
    a.toc-level-4 {
      margin-left: 3em;
    }
    a.toc-level-5 {
      margin-left: 4em;
    }
    a.toc-level-6 {
      margin-left: 5em;
    }

@context {page} .

@returns {template.HTML}

@example {{ partial "toc-parse-content.html" . }}
*/}}

{{- /* Initialize. */}}
{{- $partialName := "toc-parse-content" }}

{{- /* Verify minimum required version. */}}
{{- $minHugoVersion := "0.125.6" }}
{{- if lt hugo.Version $minHugoVersion }}
  {{- errorf "The %q partial requires Hugo v%s or later." $partialName $minHugoVersion }}
{{- end }}

{{- /* Determine content path for warning and error messages. */}}
{{- $contentPath := "" }}
{{- with .File }}
  {{- $contentPath = .Path }}
{{- else }}
  {{- $contentPath = .Path }}
{{- end }}

{{- /* Get configuration. */}}
{{- $startLevel := or ($.Param "toc.startLevel" | int) 2 }}
{{- $endLevel := or ($.Param "toc.endLevel" | int) 3 }}
{{- $minNumHeadings := or ($.Param "toc.minNumHeadings" | int) 2 }}

{{- /* Get headings. */}}
{{- $headings := slice }}
{{- $ids := slice }}
{{- range findRE `(?is)<h\d.*?</h\d>` .Content }}
  {{- $level := substr . 2 1 | int }}
  {{- if and (ge $level $startLevel) (le $level $endLevel) }}
    {{- $text := replaceRE `(?is)<h\d.*?>(.+?)</h\d>` "$1" . }}
    {{- $text = trim $text " " | plainify | safeHTML }}
    {{- $id := "" }}
    {{- if findRE `\s+id=` . }}
      {{- $id = replaceRE `(?is).+?\s+id=(?:\x22|\x27)?(.*?)(?:\x22|\x27)?[\s>].+` "$1" . }}
      {{- $ids = $ids | append $id }}
      {{- if not $id }}
        {{- errorf "The %q partial detected that the %q heading has an empty ID attribute. See %s" $partialName $text $contentPath }}
      {{- end }}
    {{- else }}
      {{- errorf "The %q partial detected that the %q heading does not have an ID attribute. See %s" $partialName $text $contentPath }}
    {{- end }}
    {{- $headings = $headings | append (dict "id" $id "level" $level "text" $text) }}
  {{- end }}
{{- end }}

{{- /* Check for duplicate heading IDs. */}}
{{- $unique := slice }}
{{- $duplicates := slice }}
{{- range $ids }}
  {{- if in $unique . }}
    {{- $duplicates = $duplicates | append . }}
  {{- else }}
    {{- $unique = $unique | append . }}
  {{- end }}
{{- end }}
{{- with $duplicates }}
  {{- errorf "The %q partial detected duplicate heading IDs (%s) in %s" $partialName (delimit . ", ") $contentPath }}
{{- end }}

{{- /* Render */}}
{{- if .Params.toc }}
  {{- with $headings }}
    {{- if ge (len .) $minNumHeadings }}
      <nav class="toc">
        <div class="toc-title">
          {{ or (T "toc_title") "Table of contents" | safeHTML }}
        </div>
        {{- range . }}
          {{- $attrs := dict "class" (printf "toc-item toc-level-%d" (add 1 (sub .level $startLevel))) }}
          {{- with .id }}
            {{- $attrs = merge $attrs (dict "href" (printf "%s#%s" $.RelPermalink .)) }}
          {{- end }}
          <a
          {{- range $k, $v := $attrs }}
            {{- printf " %s=%q" $k $v | safeHTMLAttr }}
          {{- end -}}
          >{{ .text }}</a>
        {{- end }}
      </nav>
    {{- end }}
  {{- end }}
{{- end }}

To use this in a template or shortcode:

{{ partial "toc-parse-content.html" . }}

Feature comparison

In the table below, references to the methods above are abbreviated M1, M2, M3, and M4.

 M1M2M3M4
Generate TOC from a shortcode✔️✔️✔️
Generate TOC from a template or partial✔️✔️✔️✔️
Set start and end levels in site configuration✔️✔️✔️✔️
Set start and end levels in front matter2✔️✔️✔️
Set number of headings threshold in site configuration3✔️✔️✔️
Set number of headings threshold in front matter3✔️✔️✔️
Detect duplicate heading IDs✔️✔️✔️
Detect missing heading IDs✔️✔️
Customize HTML elements and attributes34✔️✔️
Create site-relative instead of page-relative links5✔️✔️
Include headings from deeply nested shortcodes✔️
Include headings from HTML within Markdown✔️

Performance comparison

Using the examples above, we tested a 1000 page site with 20 nested headings per page. These build times are the average of 5 runs:

 DescriptionBuild time
Method 1TableOfContents205 ms
Method 2Fragments to HTML207 ms
Method 3Walk headings224 ms
Method 4Parse content334 ms

Method 4 is the slowest, as expected. It parses the rendered content using regular expressions to capture each of the headings.

This test site was simple. A more realistic site with shortcodes, partials, image processing, JavaScript building, Sass transpilation, CSS purging, and minification would take longer to build. As a percentage of total build time, the difference between the TOC generation methods would be negligible.


  1. See https://discourse.gohugo.io/t/42343 for an example. ↩︎

  2. See GitHub issue #9329↩︎

  3. See GitHub issue #10543↩︎ ↩︎ ↩︎

  4. See GitHub issue #8338↩︎

  5. See GitHub issue #4735↩︎

Last modified: