Table of Contents
By Amr
Automatic table of contents generation from page headings with scroll spy and smooth scrolling.
Estimated reading time: 5 minutes
Table of Contents
Table of Contents
Automatic table of contents generation from page headings with active section highlighting.
Overview
- Auto-Generated: Extracts from h2-h6 headings
- Scroll Spy: Highlights current section
- Smooth Scroll: Animated navigation
- Responsive: Sidebar on desktop, offcanvas on mobile
Implementation
Include Template
{% include content/toc.html %}
TOC Generation
The toc.html include uses Kramdown’s built-in TOC:
<nav id="TableOfContents" class="toc">
<h2 class="toc-title">On This Page</h2>
{{ content | toc_only }}
</nav>
Or manual extraction:
<nav id="TableOfContents">
<ul class="toc-list">
{% for heading in page.content | split: '<h' %}
{% if heading contains 'id="' %}
{% assign id = heading | split: 'id="' | last | split: '"' | first %}
{% assign level = heading | slice: 0, 1 %}
{% assign text = heading | split: '>' | last | split: '<' | first %}
<li class="toc-item toc-level-{{ level }}">
<a href="#{{ id }}" class="toc-link">{{ text }}</a>
</li>
{% endif %}
{% endfor %}
</ul>
</nav>
Configuration
Enable TOC
In front matter:
---
toc: true
---
Or site-wide in _config.yml:
defaults:
- scope:
type: docs
values:
toc: true
Heading Levels
Configure which headings appear:
toc:
min_level: 2 # Start at h2
max_level: 4 # End at h4
Styling
Basic Styles
.toc {
position: sticky;
top: 80px;
max-height: calc(100vh - 100px);
overflow-y: auto;
}
.toc-title {
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 1rem;
}
.toc-list {
list-style: none;
padding: 0;
margin: 0;
}
.toc-link {
display: block;
padding: 0.25rem 0;
color: var(--bs-secondary);
text-decoration: none;
font-size: 0.875rem;
border-left: 2px solid transparent;
padding-left: 0.75rem;
}
.toc-link:hover {
color: var(--bs-primary);
}
.toc-link.active {
color: var(--bs-primary);
border-left-color: var(--bs-primary);
font-weight: 500;
}
Nested Levels
.toc-level-3 {
padding-left: 1rem;
}
.toc-level-4 {
padding-left: 2rem;
font-size: 0.8125rem;
}
Scroll Spy
Intersection Observer
function initScrollSpy() {
const headings = document.querySelectorAll('h2[id], h3[id], h4[id]');
const tocLinks = document.querySelectorAll('.toc-link');
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
tocLinks.forEach((link) => link.classList.remove('active'));
const activeLink = document.querySelector(
`.toc-link[href="#${entry.target.id}"]`
);
activeLink?.classList.add('active');
}
});
},
{ rootMargin: '-20% 0% -70% 0%' }
);
headings.forEach((heading) => observer.observe(heading));
}
Smooth Scrolling
CSS Method
html {
scroll-behavior: smooth;
}
JavaScript Method
document.querySelectorAll('.toc-link').forEach((link) => {
link.addEventListener('click', (e) => {
e.preventDefault();
const targetId = link.getAttribute('href').slice(1);
const target = document.getElementById(targetId);
const headerOffset = 80;
const position = target.offsetTop - headerOffset;
window.scrollTo({
top: position,
behavior: 'smooth'
});
history.pushState(null, '', `#${targetId}`);
});
});
Responsive Behavior
Desktop
TOC appears in right sidebar:
<aside class="d-none d-lg-block">
{% include content/toc.html %}
</aside>
Mobile
TOC in offcanvas (see Mobile TOC):
<div class="offcanvas offcanvas-end d-lg-none" id="tocSidebar">
{% include content/toc.html %}
</div>
Accessibility
ARIA Attributes
<nav id="TableOfContents"
aria-label="Table of contents"
role="navigation">
Keyboard Navigation
- Tab through TOC links
- Enter to navigate to section
- Focus moves to heading
Troubleshooting
TOC Not Generating
- Verify headings have IDs
- Check
toc: truein front matter - Ensure Kramdown processor
Scroll Spy Not Working
- Check heading IDs match TOC hrefs
- Verify Intersection Observer support
- Test observer margins
Styling Issues
- Check sticky positioning
- Verify z-index
- Test overflow behavior