Ghost CMS - Table of contents for posts and pages
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:
- By checking the post's hashtag.
- If no hashtag defines a known language, the
html-langelement (the global system language) is used. - 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!
Member discussion