6 min read

Ghost CMS - Table of contents for posts and pages

Ghost CMS lacks a native table of contents (ToC) feature, but with a simple script, you can automatically generate a dynamic ToC for your posts and pages. This guide walks you through the implementation step by step.
Ghost CMS - Table of contents for posts and pages

🇩🇪 In deutsch lesen

Why a custom table of contents?

This site is based on the Ghost CMS. Ghost is open source, wonderfully modular, and highly customizable. However, what it lacks is a post module for a table of contents. Such a standard module simply does not exist.

The Ghost website does describe a method (see here), but it requires modifications to the theme and is relatively complex and not update-proof to implement.

An interesting Reddit post (see here) gave me the idea of creating a modularly integrated table of contents. It should be easily inserted into any post with a single line and automatically generate and update itself.

I have taken the code base from Reddit, rewrote it for performance and generalization, extended and optimized it accordingly. Finally modified the style sheets to use the accent color and do a visible more attractive indentation.

Thanks to NormisB for the inspiration!


Conceptual Thoughts

The table of contents uses hashtags in posts to define a post-specific language alongside the global system language (e.g., #de, #en).

The language of the table of contents heading is determined in three steps:

  1. By checking the post's hashtag.
  2. If no hashtag defines a known language, the html-lang element (the global system language) is used.
  3. If no valid language is detected, English is used as the default.

Additionally, the table of contents automatically searches for headings in the post and lists them hierarchically based on their level (h2, h3, etc.).

Tag lists from some Ghost themes are excluded since they are usually not part of the actual content.

A notable exception is the popular Liebling theme, which deviates from Ghost standards regarding CSS classes. This has also been considered.


Installation

Integrating the table of contents is incredibly simple and requires only two steps:

1. Add CSS Styles

Insert the following CSS styles into the <head> section of your Ghost site. You can do this via the admin panel under Settings → Code Injection → Head.

<style>
/* =========================
   Ghost TOC (theme-native)
   - transparent background
   - inherits text colors from the theme
   - uses Ghost accent color only for subtle structure
   - TOC title uses theme heading font (serif in your setup)
   ========================= */

/* Container for the table of contents */
.gh-toc-container {
  margin-bottom: 20px; /* Space below the container */
  padding: 15px; /* Inner padding */

  background: transparent; /* No forced background: let the theme shine through */
  color: inherit; /* Inherit text color from the theme */

  border-left: 3px solid var(--ghost-accent-color, currentColor); /* Accent-based structure (theme-driven) */
  border-radius: 0; /* Card styling should come from the theme, not the TOC */
  box-shadow: none; /* No custom shadow: keeps it native */
}

/* Title (H2) of the table of contents
   Variant B: the title is a real <h2> without a custom class.
   We only enforce the heading font used by the theme (serif in your case). */
.gh-toc-container > h2 {
  margin: 0 0 12px 0; /* Space below the title */
  font-family: var(--gh-font-heading, var(--font-serif)); /* Match theme heading font (serif setup) */
  letter-spacing: -.01em; /* Match theme heading letter spacing */
  color: inherit; /* Theme decides the color */
}

/* Main list of the table of contents */
.gh-toc {
  list-style: none; /* Removes default bullet points or numbering */
  padding-left: 0; /* Removes default indentation */
  margin: 0; /* Removes default spacing */
}

/* Top-level list items */
.gh-toc > li {
  margin-bottom: 8px; /* Space between main list items */
  font-size: 1.05em; /* Slightly larger font size */
  font-weight: 600; /* Slightly bolder text for better readability */
}

/* Nested lists (sub-items) */
.gh-toc ul {
  padding-left: 18px; /* Indentation for sub-items */
  border-left: 2px solid var(--ghost-border-color, currentColor); /* Structure line (theme-driven) */
  margin-top: 6px; /* Space above nested lists */
  opacity: 0.9; /* Subtle hierarchy without hard colors */
}

/* Styling for sub-list items */
.gh-toc ul li {
  font-size: 0.95em; /* Smaller font size for sub-items */
  font-weight: 400; /* Normal font weight */
  position: relative; /* Positioning for list symbol */
  margin-bottom: 6px; /* Space between sub-items */
  padding-left: 10px; /* Light indentation for cleaner structure */
}

/* Small circles as bullet points for sub-items */
.gh-toc ul li::before {
  content: "•"; /* Bullet point as symbol */
  position: absolute; /* Absolute positioning relative to the list item */
  left: -12px; /* Positioning left of the text */
  color: var(--ghost-accent-color, currentColor); /* Accent bullet (theme-driven) */
  font-size: 1.1em; /* Size of the bullet point */
  line-height: 1; /* Vertical alignment */
}

/* Styling for links in the table of contents */
.gh-toc a {
  text-decoration: none; /* Removes underline */
  color: inherit; /* Inherit link color from the surrounding theme text */
  transition: color 0.2s ease-in-out; /* Smooth color transition */
}

/* Hover effect for links */
.gh-toc a:hover {
  text-decoration: underline; /* Underline on hover */
  color: var(--ghost-accent-color, currentColor); /* Use accent color on hover (theme-driven) */
}
</style>

2. Add JavaScript Code

Similarly, the following JavaScript code must be added to the footer section (under Settings → Code Injection → Footer).

<script>
document.addEventListener('DOMContentLoaded', function () {
    // Find all placeholders for the table of contents (TOC)
    const tocPlaceholders = document.querySelectorAll('.toc-placeholder');

    // Translations for the TOC title in different languages
    const tocTitles = {
        de: "Inhaltsverzeichnis", fr: "Table des matières", es: "Tabla de contenido",
        it: "Indice", nl: "Inhoudsopgave", pl: "Spis treści", pt: "Índice",
        ru: "Оглавление", zh: "目录", ja: "目次", ar: "جدول المحتويات",
        en: "Table of Contents", default: "Table of Contents"
    };

    // Allowed language codes for detecting language via body classes
    const allowedTagLangs = new Set(Object.keys(tocTitles));

    // Detects the document language based on CSS classes on the body element
    function getLanguageFromBodyClass() {
        return [...document.body.classList]
            .find(cls => cls.startsWith("tag-hash-") && allowedTagLangs.has(cls.replace("tag-hash-", "")))
            ?.replace("tag-hash-", "") || null;
    }

    // Determine the document language
    let docLang = getLanguageFromBodyClass()
        || (allowedTagLangs.has(document.documentElement.lang.split("-")[0])
            ? document.documentElement.lang.split("-")[0]
            : null)
        || "default";

    // Select the TOC title text based on the detected language
    let tocTitleText = tocTitles[docLang] || tocTitles["default"];

    // Iterate over all TOC placeholders on the page
    tocPlaceholders.forEach(tocPlaceholder => {
        // Find the main article content container
        const articleContainer =
            document.querySelector(".gh-content") ||
            document.querySelector(".l-post-content");
        if (!articleContainer) return;

        // Collect all relevant headings (excluding those inside `.m-tags`)
        const headings = [...articleContainer.querySelectorAll("h2, h3, h4")]
            .filter(h => !h.closest(".m-tags"));
        if (headings.length === 0) return;

        // Create the TOC container element
        const containerElement = document.createElement("div");
        containerElement.className = "gh-toc-container";

        // Create the TOC title as a real <h2> (theme styling applies naturally)
        const titleElement = document.createElement("h2");
        titleElement.textContent = tocTitleText;
        containerElement.appendChild(titleElement);

        // Create the main TOC list
        const tocList = document.createElement("ul");
        tocList.className = "gh-toc";
        containerElement.appendChild(tocList);

        // Initialize variables for managing TOC nesting levels
        let lastLevel = 2;
        let levelMap = { 2: tocList };
        let currentList = tocList;

        // Process each heading and build the TOC structure
        headings.forEach(heading => {
            let level = parseInt(heading.tagName.substring(1), 10);

            // Normalize level jumps (e.g. h2 -> h4 becomes h2 -> h3)
            if (level > lastLevel + 1) {
                level = lastLevel + 1;
            }

            // Generate an ID for the heading if none exists
            if (!heading.id) {
                heading.id = heading.textContent
                    .trim()
                    .toLowerCase()
                    .replace(/\s+/g, "-")
                    .replace(/[^\w-]/g, "");
            }

            // Create a list item linking to the heading
            const listItem = document.createElement("li");
            const link = document.createElement("a");
            link.textContent = heading.textContent;
            link.href = `#${heading.id}`;
            listItem.appendChild(link);

            // Move into a nested list when entering a deeper heading level
            if (level > lastLevel) {
                const parentList = levelMap[lastLevel] || tocList;

                let parentLi = parentList.lastElementChild;
                if (!parentLi) {
                    parentLi = document.createElement("li");
                    parentList.appendChild(parentLi);
                }

                const nestedList = document.createElement("ul");
                parentLi.appendChild(nestedList);

                levelMap[level] = nestedList;
                currentList = nestedList;
            } else if (level < lastLevel) {
                // Move back up to the corresponding list when heading level decreases
                currentList = levelMap[level] || tocList;
            }

            // Append the list item to the current list
            currentList.appendChild(listItem);

            // Update level tracking
            levelMap[level] = currentList;
            lastLevel = level;
        });

        // Insert the completed TOC into the placeholder
        tocPlaceholder.appendChild(containerElement);
    });
});
</script>

Integration into a Post

Once the installation is complete, you can use the table of contents in any posts or pages. Simply insert the following HTML element at the desired position:

<!-- Table of contents -->
<div class="toc-placeholder"></div>

When the page loads, a table of contents will be rendered at this position. The heading will appear in the corresponding language, and clicking on an entry will jump directly to the relevant section in the post.


Creating a Custom Snippet

After inserting the HTML element, you can click on the "Save as Snippet" function and assign a name:

After that, you can easily add the block to any post with a click:


With this method, you can create a flexible, easily integrable, and automatically updating table of contents for Ghost. Have fun testing it out!