Table of Content for Ghost CMS

Add Simple TOC to your Ghost-CMS Blog Post.

Table of Content for Ghost CMS

As a first time user of Ghost CMS, I found that it lacks a lot of what I would consider simple things, for example, a Table of Content for your blog posts.

There are apparently ways around this and they require modifying the underlaying code of the "Theme" you have selected, and if you run the "Ghost CMS" as a "selfhosted docker container", that "Theme" (Default) is not easily exposed, Any other added Theme does show up so maybe that is something to explore in the future.

But there is another way, and it may not be as pretty and customizable (Position wise) as a targeted "Theme" code edit, but it serves it's purpose as you can already see in this post, works with "ANY" theme, the TOC simple get's added to your post exactly where you want it within the main document, so I will added bellow this line.


‼️
The examples look a little strange and that is because the "Table of Contents" line is an <H2> header and the code is detecting that header and adding it to the TOC list. If you only have 1 TOC in the page this will look as expected.

Example of Collapsible TOC:

▶️ On this page:

Example of Static TOC:

Table of Contents

What does it need:

Is simple, the code looks for ant H2->H4 headings and creates a TOC with them, as you keep adding headings within your blog post, it will auto-generate the needed code.

I found this code on Reddit in this [POST] and that code was Optimized by another Reddit user u/dericke84, we must give credit where credit is due.

The code available there generates the simple TOC, however it attempts to read from Ghost variables, such as "--ghost-accent-bg", "--ghost-accent-color", "--ghost-bg-color" depending on the "Theme" some or all may not be available and you can endup with a TOC with a pure white background on your nice "Dark Theme".

You can replace the lines of code where those variables appear and add your preferred colors where needed.

The version of the TOC code I will provide here will "inherit" the current colors used for your blog's "Background Color" and "Text Color" but I also left the original code as a comment in the same or above lines.

🏆
BONUS: My version gives you the choice of both a Static or Collapsible TOC you can pick your snippet depending on the blog post needs.

Installation:

Where do you add this code? Simple in the "Code Injection" section of the "Advance" section on the "Settings" page.

You will paste the main code in the "Site header" section, and the JavaScript code in the "Site footer" section.

0:00
/0:22

Code Section:

0:00
/0:08
<style>
/* ============================
   TABLE OF CONTENTS STYLING
   ============================ */
.gh-toc-container {
  margin-bottom: 20px;
  padding: 15px;
  background: inherit;
  border-left: none;
  border-radius: 6px;
  box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
  color: inherit;
}
.gh-toc-title {
  font-size: 1.4em;
  font-weight: bold;
  margin-bottom: 12px;
  color: inherit;
}
.gh-toc {
  list-style: none;
  padding-left: 0;
  margin: 0;
}
.gh-toc > li {
  margin-bottom: 8px;
  font-size: 1.05em;
  font-weight: 600;
}
.gh-toc ul {
  padding-left: 18px;
  border-left: 2px solid var(--ghost-border-color, #ddd);
  margin-top: 6px;
}
.gh-toc ul li {
  font-size: 0.95em;
  font-weight: 400;
  position: relative;
  margin-bottom: 6px;
  padding-left: 10px;
}
.gh-toc ul li::before {
  content: "•";
  position: absolute;
  left: -12px;
  color: var(--ghost-accent-color, #007acc);
  font-size: 1.2em;
  line-height: 1;
}
.gh-toc a {
  text-decoration: none;
  color: inherit;
  transition: color 0.2s ease-in-out, transform 0.1s ease-in-out;
}
.gh-toc a:hover {
  text-decoration: underline;
  color: inherit;
  transform: translateX(3px);
}

/* ============================
   COLLAPSIBLE TOC WRAPPER
   ============================ */
.gh-toc-wrapper {
  margin-bottom: 20px;
  border-left: 4px solid var(--ghost-accent-color, #007acc);
  border-radius: 6px;
  box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
  background: inherit;
  overflow: hidden;
}
.gh-toc-header {
  display: flex;
  align-items: center;
  cursor: pointer;
  padding: 12px 15px;
  font-weight: bold;
  font-size: 1.2em;
  border-bottom: none;
  color: inherit;
  transition: background-color 0.2s ease-in-out;
}
.gh-toc-header:hover {
  background-color: rgba(0, 0, 0, 0.30); /* strong hover highlight */
}
.gh-toc-icon {
  margin-right: 10px;
  font-size: 1.2em;
  transition: transform 0.2s ease-in-out;
}
.gh-toc-header.expanded .gh-toc-icon {
  transform: rotate(90deg); /* rotate arrow when expanded */
}

/* Collapsible content */
#gh-toc-content {
  padding: 0 15px;
  transition: max-height 0.3s ease-out, opacity 0.3s ease-out;
  max-height: 0;
  opacity: 0;
  overflow: hidden;
}
#gh-toc-content.expanded {
  opacity: 1;
}
</style>

Paste inside "Site header" section

0:00
/0:08
<script>
/* ============================
   TABLE OF CONTENTS GENERATION
   ============================ */
document.addEventListener('DOMContentLoaded', function () {
    const tocPlaceholders = document.querySelectorAll('.toc-placeholder');
    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"
    };
    const allowedTagLangs = new Set(Object.keys(tocTitles));

    function getLanguageFromBodyClass() {
        return [...document.body.classList]
            .find(cls => cls.startsWith("tag-hash-") && allowedTagLangs.has(cls.replace("tag-hash-", "")))
            ?.replace("tag-hash-", "") || null;
    }

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

    tocPlaceholders.forEach(tocPlaceholder => {
        const articleContainer = document.querySelector(".gh-content") || document.querySelector(".l-post-content");
        if (!articleContainer) return;

        const headings = [...articleContainer.querySelectorAll("h2, h3, h4")].filter(h => !h.closest(".m-tags"));
        if (headings.length === 0) return;

        const containerElement = document.createElement("div");
        containerElement.className = "gh-toc-container";

        const titleElement = document.createElement("h2");
        titleElement.className = "gh-toc-title";
        titleElement.textContent = tocTitleText;
        containerElement.appendChild(titleElement);

        const tocList = document.createElement("ul");
        tocList.className = "gh-toc";
        containerElement.appendChild(tocList);

        let lastLevel = 2;
        let levelMap = { 2: tocList };
        let currentList = tocList;

        headings.forEach(heading => {
            const level = parseInt(heading.tagName.substring(1));
            if (!heading.id) {
                heading.id = heading.textContent.trim().toLowerCase()
                    .replace(/\s+/g, "-").replace(/[^\w-]/g, "");
            }

            const listItem = document.createElement("li");
            const link = document.createElement("a");
            link.textContent = heading.textContent;
            link.href = `#${heading.id}`;
            listItem.appendChild(link);

            if (level > lastLevel) {
                const nestedList = document.createElement("ul");
                levelMap[lastLevel].lastElementChild.appendChild(nestedList);
                levelMap[level] = nestedList;
                currentList = nestedList;
            } else if (level < lastLevel) {
                currentList = levelMap[level] || tocList;
            }

            currentList.appendChild(listItem);
            levelMap[level] = currentList;
            lastLevel = level;
        });

        tocPlaceholder.appendChild(containerElement);
    });
});

/* ============================
   COLLAPSIBLE TOC TOGGLE
   ============================ */
function toggleTOC() {
    const toc = document.getElementById('gh-toc-content');
    const header = document.querySelector('.gh-toc-header');

    if (toc.classList.contains('expanded')) {
        // Collapse
        toc.style.maxHeight = toc.scrollHeight + "px"; // set current height
        requestAnimationFrame(() => {
            toc.style.maxHeight = "0";
            toc.classList.remove('expanded');
            header.classList.remove('expanded');
        });
    } else {
        // Expand
        toc.classList.add('expanded');
        header.classList.add('expanded');
        toc.style.maxHeight = toc.scrollHeight + "px";
    }
}
</script>

Paste inside "Site footer" section

Once this is pasted in the "Code Injection" section you are done with the main code and only left with just one more code that you will paste on your blog post where ever you want the TOC to appear, we will save this code as a "Code Snippet" this way it will become part of the "Ghost Editing Menu" and you will simple selected in the future.

Code Snippets:

0:00
/0:42

Collapsible Version:

<div class="gh-toc-wrapper">
  <div class="gh-toc-header" onclick="toggleTOC()">
    <span class="gh-toc-icon">▶️</span>
    <span class="gh-toc-title">On this page:</span>
  </div>
  <div class="gh-toc-container" id="gh-toc-content">
    <div class="toc-placeholder"></div>
  </div>
</div>

TOC - Collapsible code snippet

Static Version:

<div class="gh-toc-container">
  <h2 class="gh-toc-title">Table of Contents</h2>
  <div class="toc-placeholder"></div>
</div>

TOC - Static code snippet

Conclusion:

And that is how simple it is to add a "Bare Bones" TOC to your Ghost CMS Blog.
I hope you found this info as useful as I did and if you be so kind you can take a look at my YouTube Channel and subscribe as a way to help me grow.

Techy-Notes
Collection of things I find Interesting, reviews and the occasional tutorial.