Add a table of contents to Ghost posts

Ghost CMS lacks a native table of contents (ToC) feature. With a small script injected into the site header and footer, a dynamic ToC can be generated automatically for any post or page.

Installation

1. Add CSS Styles

Add the following to Settings > Advanced > Code Injection > Site header:

<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

Add the following to Settings > Advanced > Code Injection > Site 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 = {
      ar: "جدول المحتويات",
      de: "Inhaltsverzeichnis",
      en: "Table of Contents",
      es: "Tabla de contenido",
      fr: "Table des matières",
      it: "Indice",
      ja: "目次",
      nl: "Inhoudsopgave",
      pl: "Spis treści",
      pt: "Índice",
      ru: "Оглавление",
      zh: "目录",
      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"];

    // Track generated heading IDs so duplicate heading text doesn't collide
    const usedIds = new Set();

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

      // Collect all relevant headings
      const headings = [...articleContainer.querySelectorAll("h2, h3, h4")];
      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, de-duplicating
        // when two headings have the same text (e.g. two "Installation" H2s)
        if (!heading.id) {
          const baseId = heading.textContent
            .trim()
            .toLowerCase()
            .replace(/\s+/g, "-")
            .replace(/[^\w-]/g, "");

          let id = baseId;
          let suffix = 1;
          while (usedIds.has(id)) {
            id = `${baseId}-${++suffix}`;
          }
          usedIds.add(id);
          heading.id = id;
        }

        // 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

After adding the injected code, you can add a table of contents to any page by inserting an HTML element with the following contents:

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

To simplify usage, you can save the HTML element as a snippet.

Source

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.