Реализация Table of Contents для длинных статей на сайте
Table of Contents (TOC) автоматически строит навигацию из заголовков статьи, подсвечивает текущий раздел при скролле и позволяет быстро перемещаться по длинному тексту.
Автогенерация из 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) => {
// Генерируем id если нет
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
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', 'Содержание статьи')
nav.className = 'toc'
const title = document.createElement('div')
title.className = 'toc__title'
title.textContent = 'Содержание'
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)
}
Подсветка активного раздела
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) {
// Убираем активный с предыдущего
document.querySelectorAll('.toc__link--active').forEach(el => {
el.classList.remove('toc__link--active')
})
tocLink?.classList.add('toc__link--active')
// Скроллим TOC к активному пункту (если TOC со скроллом)
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-компонент с 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) => {
// Берём самый верхний пересекающийся заголовок
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)
// Автоскролл TOC к активному пункту
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="Содержание">
<div className="toc__header">Содержание</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 для 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 сворачивается в аккордеон на мобильных */
.toc {
position: static;
max-height: none;
}
}
Генерация TOC из Markdown на сервере
Если контент хранится в Markdown, генерируем TOC при парсинге:
// Laravel: парсинг markdown с автоматическими ID для заголовков
// 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', // или 'top'
'placeholder' => '[TOC]',
'style' => 'ordered',
'min_heading_level' => 2,
'max_heading_level' => 4,
'normalize' => 'relative',
],
]);
$environment->addExtension(new HeadingPermalinkExtension());
$environment->addExtension(new TableOfContentsExtension());
Сроки
TOC из DOM с подсветкой и sticky позиционированием — 1 день. С мобильным аккордеоном, серверной генерацией из Markdown и Schema.org разметкой — 1.5–2 дня.







