Table of Contents¶
Automatic table of contents generation from page headings with active section highlighting.

The On this page panel on the right is the table of contents, built from the page's h2–h6 headings.
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¶
TOC Generation¶
The toc.html include uses Kramdown's built-in TOC:
{% raw %}<nav id="TableOfContents" class="toc">
<h2 class="toc-title">On This Page</h2>
{{ content | toc_only }}
</nav>{% endraw %}
Or manual extraction:
{% raw %}<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>{% endraw %}
Configuration¶
Enable TOC¶
In front matter:
Or site-wide in _config.yml:
Heading Levels¶
Configure which headings appear:
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¶
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¶
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:
Mobile¶
TOC in offcanvas (see Mobile TOC):
<div class="offcanvas offcanvas-end d-lg-none" id="tocSidebar">
{% raw %}{% include content/toc.html %}{% endraw %}
</div>
Accessibility¶
ARIA Attributes¶
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
Related¶
See also¶
- [[Features]]
- [[Mobile TOC Floating Action Button]]
- [[Sidebar Navigation System]]