How to Add a Table of Contents to Jekyll Posts
Add an automatic table of contents to your Jekyll posts — using kramdown's built-in TOC, the jekyll-toc plugin, or a JavaScript sticky sidebar with scroll highlighting.
A table of contents does two things simultaneously: it helps readers understand the scope of what they are about to read and lets them jump directly to the section they need. For SEO, a structured TOC with anchor links signals to Google that your content is organised and comprehensive — it is a reliable indicator of long-form, high-quality posts.
Jekyll gives you three distinct approaches to adding a TOC, ranging from a one-line Markdown shortcut to a fully interactive sticky sidebar with scroll highlighting. Each has its place. This guide covers all three so you can choose the right one for your situation.
When to use a table of contents
Not every post benefits from a TOC. Short posts under 500 words or posts with only two or three headings do not need one — a TOC on a brief article feels like an overly formal contents page in a pamphlet.
The sweet spot for a TOC is posts with four or more main sections, particularly:
- Technical tutorials with distinct steps
- Reference guides covering multiple topics
- Comparison posts with a section per option
- Documentation pages
- Any post where readers are likely to scan for a specific section rather than read linearly
A good rule of thumb: if you find yourself adding a front matter field toc: true to control it, you are already thinking about this correctly — make it opt-in per post.
Option 1: kramdown built-in TOC
Jekyll’s default Markdown processor is kramdown, which includes a built-in TOC generator. This is the fastest approach — no plugin, no JavaScript, no dependencies.
Add the TOC to a post
Place this snippet anywhere in your Markdown file where you want the TOC to appear — typically at the top of the post body, after a brief introduction:
* TOC
{:toc}
That is all. kramdown scans all headings in the document and generates a nested <ul> list with anchor links. The * TOC is a required placeholder (kramdown replaces the entire list with the generated TOC), and {:toc} is the kramdown attribute that triggers the TOC generation.
What it generates
For a post with this heading structure:
## Introduction
## Setting Up Jekyll
### Installation
### Configuration
## Writing Your First Post
## Conclusion
The generated TOC looks like:
<ul id="markdown-toc">
<li><a href="#introduction">Introduction</a></li>
<li><a href="#setting-up-jekyll">Setting Up Jekyll</a>
<ul>
<li><a href="#installation">Installation</a></li>
<li><a href="#configuration">Configuration</a></li>
</ul>
</li>
<li><a href="#writing-your-first-post">Writing Your First Post</a></li>
<li><a href="#conclusion">Conclusion</a></li>
</ul>
kramdown automatically assigns the id="markdown-toc" to the list, which you can use for styling.
Style the TOC
The generated TOC is a plain <ul>. Add CSS to make it look polished:
// _sass/components/_toc.scss
#markdown-toc {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-left: 3px solid var(--color-primary);
border-radius: var(--radius-md);
padding: 1.25rem 1.5rem;
margin-bottom: 2rem;
font-size: 0.9rem;
&::before {
content: "On This Page";
display: block;
font-weight: 700;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
margin-bottom: 0.75rem;
}
li {
margin: 0.3rem 0;
list-style: none;
}
ul {
padding-left: 1rem;
margin: 0.25rem 0;
}
a {
color: var(--text-muted);
text-decoration: none;
transition: color 0.15s;
&:hover { color: var(--link-color); }
}
}
Wrap it in a div for more control
To add a CSS class to the TOC container:
<div class="toc-box" markdown="1">
* TOC
{:toc}
</div>
The markdown="1" attribute tells kramdown to process the Markdown inside the HTML block.
Exclude specific headings
Add {: .no_toc} after any heading you want to exclude from the TOC:
## This Appears in the TOC
## Introduction {: .no_toc}
Introductory headings are often excluded because readers can see them without scrolling — the TOC is most useful for sections further down.
Limit TOC to specific heading levels
In _config.yml, restrict which heading levels appear in the TOC:
kramdown:
toc_levels: "2..3" # Only H2 and H3 — exclude H4, H5, H6
This prevents deeply nested sub-sections from cluttering the TOC. Most posts only need H2 and H3 in the contents list.
Option 2: jekyll-toc plugin
The jekyll-toc plugin gives you more flexibility than kramdown’s built-in approach: you can use the TOC in layouts (not just post bodies), inject it into a sidebar, and control it per post with front matter.
Install
# Gemfile
gem "jekyll-toc"
# _config.yml
plugins:
- jekyll-toc
Run bundle install.
Note: jekyll-toc is not on GitHub Pages’ approved plugin list. If you deploy to GitHub Pages, you need to use GitHub Actions to build your site.
Use it in your post layout
In _layouts/post.html, the plugin provides two Liquid filters:
<div class="post-wrapper">
{% if page.toc %}
<aside class="post-toc">
<p class="post-toc__title">Contents</p>
{{ content | toc_only }}
</aside>
{% endif %}
<article class="post-content">
{{ content | inject_anchors }}
</article>
</div>
toc_only extracts and renders just the TOC as a standalone nav block. inject_anchors processes the post body and adds id attributes to all headings so the TOC links work correctly.
Control per post with front matter
Add toc: true to a post to show the TOC:
---
title: "My Comprehensive Guide"
toc: true
---
In _config.yml, set a default so you do not need to add it to every post:
defaults:
- scope:
path: ""
type: posts
values:
toc: false # Off by default
Then just add toc: true to posts that need it.
Option 3: JavaScript sticky TOC with scroll highlighting
For technical documentation, long tutorials, or posts where readers will jump around frequently, a sticky sidebar TOC with scroll highlighting is the most user-friendly approach. It stays visible as the reader scrolls and marks the current section.
HTML structure in your post layout
Wrap the post content in a two-column grid:
{% if page.toc %}
<div class="post-layout">
<article class="post-layout__content">
{{ content }}
</article>
<aside class="post-layout__toc" aria-label="Table of contents">
<nav id="toc-nav">
<p class="toc-heading">On This Page</p>
<ul id="toc-list"></ul>
</nav>
</aside>
</div>
{% else %}
<article class="post-layout__content post-layout__content--full">
{{ content }}
</article>
{% endif %}
JavaScript: build the TOC and track scroll position
// assets/js/toc.js
(function () {
const tocList = document.getElementById('toc-list');
if (!tocList) return;
// Collect headings from the post content
const content = document.querySelector('.post-layout__content');
if (!content) return;
const headings = Array.from(content.querySelectorAll('h2, h3'));
// Hide TOC sidebar if there are fewer than 3 headings
if (headings.length < 3) {
const sidebar = document.querySelector('.post-layout__toc');
if (sidebar) sidebar.hidden = true;
return;
}
// Build the TOC list
headings.forEach((heading, index) => {
// Ensure each heading has an anchor ID
if (!heading.id) {
heading.id = heading.textContent
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-');
}
const li = document.createElement('li');
li.className = `toc-item toc-item--${heading.tagName.toLowerCase()}`;
li.innerHTML = `
<a href="#${heading.id}" class="toc-link" data-heading="${heading.id}">
${heading.textContent}
</a>`;
tocList.appendChild(li);
});
// Scroll spy using IntersectionObserver
let activeId = null;
const observer = new IntersectionObserver(
(entries) => {
// Find the topmost visible heading
const visible = entries
.filter(e => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
if (visible.length > 0) {
const id = visible[0].target.id;
if (id !== activeId) {
activeId = id;
// Update active state
tocList.querySelectorAll('.toc-link').forEach(link => {
link.classList.toggle('toc-link--active', link.dataset.heading === id);
});
}
}
},
{ rootMargin: '-10% 0px -80% 0px' }
);
headings.forEach(h => observer.observe(h));
// Smooth scroll for TOC links
tocList.addEventListener('click', (e) => {
const link = e.target.closest('.toc-link');
if (!link) return;
e.preventDefault();
const target = document.getElementById(link.dataset.heading);
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
// Update URL without reload
history.pushState(null, '', `#${link.dataset.heading}`);
}
});
})();
CSS for the two-column layout and sticky TOC
// Post layout
.post-layout {
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
align-items: start;
@media (min-width: 1200px) {
grid-template-columns: 1fr 240px;
}
}
.post-layout__content {
min-width: 0; // Prevent grid blowout from wide code blocks
}
// Sticky TOC sidebar
.post-layout__toc {
display: none;
@media (min-width: 1200px) {
display: block;
position: sticky;
top: 2rem;
max-height: calc(100vh - 4rem);
overflow-y: auto;
scrollbar-width: thin;
}
}
.toc-heading {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
margin-bottom: 0.75rem;
}
#toc-list {
list-style: none;
padding: 0;
margin: 0;
border-left: 2px solid var(--border-color);
}
.toc-item { padding: 0 0 0.15rem; }
.toc-item--h3 .toc-link {
padding-left: 1.5rem;
font-size: 0.82rem;
}
.toc-link {
display: block;
padding: 0.2rem 0 0.2rem 0.875rem;
margin-left: -2px;
border-left: 2px solid transparent;
color: var(--text-muted);
text-decoration: none;
line-height: 1.4;
font-size: 0.875rem;
transition: color 0.12s, border-color 0.12s;
&:hover { color: var(--text-color); }
&--active {
color: var(--color-primary);
border-left-color: var(--color-primary);
font-weight: 600;
}
}
The rootMargin: '-10% 0px -80% 0px' in the IntersectionObserver creates a horizontal “detection band” across roughly the middle 10% of the viewport height. As the reader scrolls, whichever heading enters this band is marked as active in the TOC. This gives a natural feel — the active link updates just as the corresponding section becomes the primary focus.
Comparing the three approaches
kramdown built-in is the right choice for most blogs. Zero setup, zero dependencies, works everywhere including GitHub Pages. The trade-off is positioning: the TOC appears inline in the post body, not in a sidebar. If that is acceptable (and for most posts it is), use this.
jekyll-toc plugin is useful when you need the TOC separate from the content — particularly when building a layout where the TOC goes in a sidebar column. It requires GitHub Actions for GitHub Pages deployment, but offers more layout flexibility.
JavaScript TOC is the best experience for readers of long technical content. The sticky sidebar stays in view throughout the article, and the scroll-highlighted active section removes any “where am I?” confusion. The cost is a small amount of JavaScript and a two-column CSS grid. For documentation sites and comprehensive tutorials, this is worth it.
Front matter default pattern
Whichever approach you use, control the TOC with a toc: front matter field:
# In your post:
---
toc: true
---
Set a global default so you opt in rather than out:
# _config.yml
defaults:
- scope:
type: posts
values:
toc: false
Then your layout checks page.toc before rendering:
{% if page.toc %}
<!-- TOC here -->
{% endif %}
A table of contents is one of those small improvements that readers notice immediately, even if they do not consciously recognise what changed. The page feels more navigable and professional. Browse Jekyll themes on JekyllHub — themes like Just the Docs and Chirpy include polished TOC implementations you can study and adapt.
Automatically generating TOC anchors in Jekyll
Any TOC implementation relies on anchor links matching heading IDs. Jekyll’s Markdown processor (Kramdown by default) automatically generates an HTML id attribute on each heading derived from the heading text — spaces become hyphens, special characters are removed, and the result is lowercased. So ## Advanced Configuration becomes <h2 id="advanced-configuration">.
You can override the auto-generated ID with an explicit one in Kramdown:
## Advanced Configuration {#config}
This produces <h2 id="config">, which you can link to as #config. Explicit IDs are useful when heading text is long or contains characters that produce ugly auto-generated IDs, or when you want stable anchor links that survive heading text changes.
For TOC generation, knowing that Kramdown’s ID algorithm is consistent means you can pre-compute TOC entries without parsing rendered HTML. The JavaScript TOC approach reads computed IDs from the DOM at runtime, so it always stays in sync with whatever IDs Kramdown generates.
TOC in collections and documentation sites
Jekyll collections — the _docs/, _api/, or _guides/ directories configured in _config.yml — are common homes for long documentation pages that benefit most from a TOC. Collections work identically to posts for TOC purposes, since Kramdown processes all Markdown files regardless of where they live.
Documentation themes like Just the Docs and Chirpy include built-in TOC components with auto-generation, scroll tracking, and collapsible subsections. If you are building a documentation site, starting with one of these themes gives you a polished TOC out of the box without custom implementation. The JekyllHub theme directory has a Documentation category where you can browse themes that include TOC functionality.
For hand-built documentation sites, the Jekyll plugin jekyll-toc installs via Gemfile and adds a toc Liquid filter. Apply it to page content: {{ content | toc }} renders a full TOC, and {{ content | toc_only }} renders the TOC without the content. This is the most maintainable server-side approach — no JavaScript, no custom include files, just a filter.
Multi-level TOC: handling H2 and H3
Most articles benefit from a two-level TOC: H2 headings as the primary structure, H3 headings nested beneath their parent H2. Anything deeper than two levels typically indicates the article is too broad — consider splitting it, not nesting further in the TOC.
Implementing a multi-level TOC in pure Liquid is complex because Liquid lacks recursion. The alloc approach is to collect headings with a regex capture and manage indentation manually, which works but is brittle. The JavaScript approach handles nesting naturally: scan headings in document order, track the current nesting level by comparing tagName values, and push H3 items into the last H2’s children array before rendering.
A simple two-level JavaScript TOC renderer:
const headings = document.querySelectorAll('.post-content h2, .post-content h3');
const toc = document.getElementById('toc');
let currentH2Li = null;
let currentSubList = null;
headings.forEach(heading => {
const link = document.createElement('a');
link.href = '#' + heading.id;
link.textContent = heading.textContent;
const li = document.createElement('li');
li.appendChild(link);
if (heading.tagName === 'H2') {
currentSubList = document.createElement('ul');
li.appendChild(currentSubList);
toc.appendChild(li);
currentH2Li = li;
} else if (heading.tagName === 'H3' && currentSubList) {
currentSubList.appendChild(li);
}
});
This produces a nested list where H3 items appear indented under their parent H2. Style the nested list with padding-left: 1rem and a smaller font-size to visually communicate the hierarchy.
Accessibility requirements for table of contents
A TOC is a navigational landmark and must be accessible to screen reader users and keyboard navigators. Use a <nav> element with an aria-label attribute to identify it: <nav aria-label="Table of contents">. This lets screen reader users find the TOC quickly via the landmark navigation shortcut.
Ensure all TOC links are keyboard-reachable and have visible focus styles. The link text should match the heading text exactly — do not abbreviate or paraphrase, because screen reader users navigate by hearing the link text and then expect to land on a heading with that same text.
If your TOC uses scroll-spy to highlight the active section, make sure the active state is communicated to assistive technology. Adding aria-current="true" to the currently active TOC link signals to screen readers which section the user is currently reading. Update it via JavaScript as the active section changes.
Skip links — <a href="#main-content" class="skip-link">Skip to main content</a> at the top of the page — allow keyboard users to bypass both the site navigation and the TOC to jump directly to article content. These are a standard accessibility requirement (WCAG 2.4.1) for pages with repeated navigation blocks.