Implementing Table of Contents for Long Articles on Website
Table of Contents (TOC) automatically builds navigation from article headings, highlights the current section while scrolling, and allows quick navigation through long text.
Auto-generation from DOM
interface TocItem {
id: string
text: string
level: number
element: HTMLElement
}
function buildToc(contentSelector: string = 'article'): TocItem[] {
const content = document.querySelector(contentSelector)
if (!content) return []
const headings = content.querySelectorAll<HTMLHeadingElement>('h2, h3, h4')
const toc: TocItem[] = []
headings.forEach((heading, index) => {
// Generate id if missing
if (!heading.id) {
heading.id = heading.textContent!
.toLowerCase()
.trim()
.replace(/[^\wа-яё\s-]/gi, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
+ `-${index}`
}
toc.push({
id: heading.id,
text: heading.textContent!.trim(),
level: parseInt(heading.tagName[1]),
element: heading,
})
})
return toc
}
TOC Rendering
function renderToc(items: TocItem[], container: HTMLElement) {
if (items.length < 3) {
container.hidden = true
return
}
const minLevel = Math.min(...items.map(i => i.level))
const nav = document.createElement('nav')
nav.setAttribute('aria-label', 'Article contents')
nav.className = 'toc'
const title = document.createElement('div')
title.className = 'toc__title'
title.textContent = 'Contents'
nav.appendChild(title)
const list = document.createElement('ol')
list.className = 'toc__list'
items.forEach(item => {
const li = document.createElement('li')
li.className = `toc__item toc__item--level-${item.level - minLevel + 1}`
li.dataset.tocId = item.id
const a = document.createElement('a')
a.href = `#${item.id}`
a.textContent = item.text
a.className = 'toc__link'
a.addEventListener('click', (e) => {
e.preventDefault()
const target = document.getElementById(item.id)!
const headerHeight = (document.querySelector('.site-header') as HTMLElement)?.offsetHeight ?? 0
const top = target.getBoundingClientRect().top + window.scrollY - headerHeight - 16
window.scrollTo({ top, behavior: 'smooth' })
history.pushState(null, '', `#${item.id}`)
})
li.appendChild(a)
list.appendChild(li)
})
nav.appendChild(list)
container.appendChild(nav)
}
Highlighting Active Section
function activateTocTracking(items: TocItem[]) {
const headerHeight = (document.querySelector('.site-header') as HTMLElement)?.offsetHeight ?? 64
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
const id = entry.target.id
const tocLink = document.querySelector<HTMLElement>(`[data-toc-id="${id}"] .toc__link`)
if (entry.isIntersecting) {
// Remove active from previous
document.querySelectorAll('.toc__link--active').forEach(el => {
el.classList.remove('toc__link--active')
})
tocLink?.classList.add('toc__link--active')
// Scroll TOC to active item (if TOC has scroll)
tocLink?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
})
},
{
rootMargin: `-${headerHeight + 16}px 0px -70% 0px`,
threshold: 0,
}
)
items.forEach(item => observer.observe(item.element))
return () => observer.disconnect()
}
React Component with Sticky Sidebar
import { useEffect, useState, useRef, useCallback } from 'react'
interface TocItem {
id: string
text: string
level: number
}
function useActiveTocItem(items: TocItem[]): string {
const [activeId, setActiveId] = useState(items[0]?.id ?? '')
useEffect(() => {
if (!items.length) return
const headerHeight = document.querySelector<HTMLElement>('.site-header')?.offsetHeight ?? 64
const observer = new IntersectionObserver(
(entries) => {
// Get topmost intersecting heading
const visible = entries
.filter(e => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
if (visible.length > 0) {
setActiveId(visible[0].target.id)
}
},
{ rootMargin: `-${headerHeight + 16}px 0px -60% 0px` }
)
items.forEach(item => {
const el = document.getElementById(item.id)
if (el) observer.observe(el)
})
return () => observer.disconnect()
}, [items])
return activeId
}
export function TableOfContents({ items }: { items: TocItem[] }) {
const activeId = useActiveTocItem(items)
const activeRef = useRef<HTMLAnchorElement>(null)
// Auto-scroll TOC to active item
useEffect(() => {
activeRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}, [activeId])
if (items.length < 3) return null
const minLevel = Math.min(...items.map(i => i.level))
function handleClick(e: React.MouseEvent<HTMLAnchorElement>, id: string) {
e.preventDefault()
const target = document.getElementById(id)
if (!target) return
const headerHeight = document.querySelector<HTMLElement>('.site-header')?.offsetHeight ?? 0
window.scrollTo({
top: target.getBoundingClientRect().top + window.scrollY - headerHeight - 16,
behavior: 'smooth',
})
history.pushState(null, '', `#${id}`)
}
return (
<nav className="toc" aria-label="Contents">
<div className="toc__header">Contents</div>
<ol className="toc__list">
{items.map(item => (
<li
key={item.id}
className={`toc__item toc__item--l${item.level - minLevel + 1}`}
>
<a
href={`#${item.id}`}
ref={activeId === item.id ? activeRef : undefined}
className={`toc__link ${activeId === item.id ? 'toc__link--active' : ''}`}
aria-current={activeId === item.id ? 'location' : undefined}
onClick={e => handleClick(e, item.id)}
>
{item.text}
</a>
</li>
))}
</ol>
</nav>
)
}
CSS for Sticky TOC
.article-layout {
display: grid;
grid-template-columns: 1fr 260px;
gap: 40px;
align-items: start;
}
.toc {
position: sticky;
top: calc(var(--header-height, 64px) + 24px);
max-height: calc(100vh - var(--header-height, 64px) - 48px);
overflow-y: auto;
overscroll-behavior: contain;
padding: 20px;
background: #f8fafc;
border-radius: 12px;
border-left: 3px solid #6366f1;
font-size: 14px;
}
.toc__list {
list-style: none;
padding: 0;
margin: 0;
counter-reset: toc;
}
.toc__item--l1 { padding-left: 0; }
.toc__item--l2 { padding-left: 16px; }
.toc__item--l3 { padding-left: 32px; }
.toc__link {
display: block;
padding: 4px 0;
color: #64748b;
text-decoration: none;
line-height: 1.4;
transition: color 0.15s;
border-left: 2px solid transparent;
padding-left: 8px;
margin-left: -8px;
}
.toc__link:hover {
color: #1e293b;
}
.toc__link--active {
color: #6366f1;
border-left-color: #6366f1;
font-weight: 500;
}
@media (max-width: 1024px) {
.article-layout {
grid-template-columns: 1fr;
}
/* TOC collapses into accordion on mobile */
.toc {
position: static;
max-height: none;
}
}
Generating TOC from Markdown on Server
If content is stored in Markdown, generate TOC during parsing:
// Laravel: parse markdown with automatic IDs for headings
// composer require league/commonmark
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\HeadingPermalink\HeadingPermalinkExtension;
use League\CommonMark\Extension\TableOfContents\TableOfContentsExtension;
$environment = new Environment([
'heading_permalink' => [
'html_class' => 'heading-permalink',
'id_prefix' => '',
'apply_id_to_heading' => true,
'heading_class' => '',
'fragment_prefix' => '',
'insert' => 'after',
],
'table_of_contents' => [
'html_class' => 'toc',
'position' => 'placeholder', // or 'top'
'placeholder' => '[TOC]',
'style' => 'ordered',
'min_heading_level' => 2,
'max_heading_level' => 4,
'normalize' => 'relative',
],
]);
$environment->addExtension(new HeadingPermalinkExtension());
$environment->addExtension(new TableOfContentsExtension());
Timeframe
TOC from DOM with highlighting and sticky positioning — 1 day. With mobile accordion, server-side generation from Markdown, and Schema.org markup — 1.5–2 days.







