Оглавление для длинной статьи — та задача, где готовая библиотека обычно избыточна, а своя реализация занимает сотню строк. Вопрос только в том, какие сто строк: наивная версия ломается на кириллице, на повторяющихся заголовках и на подсветке активного пункта.
Разберём реализацию, где эти три случая обработаны, и почему подсветка сделана через IntersectionObserver, а не через обработчик прокрутки.
Задача
Автоматически генерировать оглавление статьи на основе заголовков. При клике — плавный скролл к нужному разделу, с подсветкой текущего пункта.
Базовая реализация
class TableOfContents {
constructor(options = {}) {
this.contentSelector = options.contentSelector || '.article-content';
this.tocSelector = options.tocSelector || '.table-of-contents';
this.headingSelector = options.headingSelector || 'h2, h3, h4';
this.smoothScroll = options.smoothScroll ?? true;
this.offset = options.offset || 80; // Отступ для fixed header
this.headings = [];
this.tocElement = null;
this.init();
}
init() {
const content = document.querySelector(this.contentSelector);
this.tocElement = document.querySelector(this.tocSelector);
if (!content || !this.tocElement) {
return;
}
this.headings = Array.from(
content.querySelectorAll(this.headingSelector)
);
if (this.headings.length === 0) {
this.tocElement.style.display = 'none';
return;
}
this.generateIds();
this.renderToc();
this.setupScrollSpy();
}
/**
* Генерация ID для заголовков
*/
generateIds() {
const usedIds = new Set();
this.headings.forEach((heading, index) => {
if (!heading.id) {
let baseId = this.slugify(heading.textContent);
let id = baseId;
let counter = 1;
// Уникальность ID
while (usedIds.has(id)) {
id = `${baseId}-${counter++}`;
}
heading.id = id;
}
usedIds.add(heading.id);
});
}
/**
* Преобразование текста в slug
*/
slugify(text) {
return text
.toLowerCase()
.trim()
.replace(/[^\w\sа-яё-]/gi, '')
.replace(/[\s_]+/g, '-')
.replace(/^-+|-+$/g, '')
.substring(0, 50) || `heading-${Date.now()}`;
}
/**
* Рендер оглавления
*/
renderToc() {
const list = document.createElement('ul');
list.className = 'toc-list';
this.headings.forEach((heading, index) => {
const item = document.createElement('li');
const level = parseInt(heading.tagName[1]);
item.className = `toc-item toc-item--level-${level}`;
item.innerHTML = `
<a href="#${heading.id}" class="toc-link" data-index="${index}">
${heading.textContent}
</a>
`;
list.appendChild(item);
});
this.tocElement.innerHTML = '';
this.tocElement.appendChild(list);
// Обработчики кликов
this.tocElement.querySelectorAll('.toc-link').forEach(link => {
link.addEventListener('click', (e) => this.handleClick(e));
});
}
/**
* Обработка клика по ссылке
*/
handleClick(e) {
if (!this.smoothScroll) return;
e.preventDefault();
const targetId = e.target.getAttribute('href').slice(1);
const target = document.getElementById(targetId);
if (target) {
const top = target.getBoundingClientRect().top + window.scrollY - this.offset;
window.scrollTo({
top,
behavior: 'smooth'
});
// Обновляем URL без перезагрузки
history.pushState(null, '', `#${targetId}`);
}
}
/**
* Отслеживание скролла для подсветки активного пункта
*/
setupScrollSpy() {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
const id = entry.target.id;
const link = this.tocElement.querySelector(`a[href="#${id}"]`);
if (entry.isIntersecting) {
// Убираем активный класс со всех
this.tocElement.querySelectorAll('.toc-link').forEach(l => {
l.classList.remove('toc-link--active');
});
// Добавляем активному
if (link) {
link.classList.add('toc-link--active');
}
}
});
},
{
rootMargin: `-${this.offset}px 0px -80% 0px`,
threshold: 0
}
);
this.headings.forEach(heading => observer.observe(heading));
}
}
// Инициализация
document.addEventListener('DOMContentLoaded', () => {
new TableOfContents({
contentSelector: '.article-content',
tocSelector: '.table-of-contents',
headingSelector: 'h2, h3',
offset: 100
});
});Почему IntersectionObserver, а не scroll
Классическая реализация подсветки активного пункта — обработчик на scroll, который на каждом событии перебирает заголовки и считает их позиции. Работает, но плохо: событие прокрутки срабатывает десятки раз в секунду, а getBoundingClientRect() внутри обработчика заставляет браузер пересчитывать раскладку. На мобильных это заметно как подтормаживающая прокрутка — то есть ровно там, где плавность важнее всего.
IntersectionObserver решает ту же задачу принципиально иначе: браузер сам сообщает, когда элемент вошёл в область видимости, без опроса и без перерасчёта раскладки в вашем коде. Плюс он не требует ручного ограничения частоты вызовов.
Про генерацию идентификаторов. Два момента, которые ломают наивную реализацию якорей.
Повторяющиеся заголовки. «Итоги» в двух разделах дадут два одинаковых id, и обе ссылки уведут к первому. В коде выше это решено счётчиком с суффиксом.
Кириллица. Русский заголовок при транслитерации может дать пустую строку (если состоял из символов, которые правило не покрывает) — и вы получите id="", то есть неработающую ссылку. Запасной вариант вида heading-{index} при пустом результате обязателен.
И третий момент, о котором стоит помнить: идентификаторы попадают в адресную строку, а значит в закладки и во внешние ссылки. Меняя правило их формирования, вы ломаете все ранее сохранённые ссылки на разделы.
CSS стили
.table-of-contents {
position: sticky;
top: 100px;
padding: 1.5rem;
background: var(--bg-secondary);
border-radius: 8px;
max-height: calc(100vh - 140px);
overflow-y: auto;
}
.toc-list {
list-style: none;
padding: 0;
margin: 0;
}
.toc-item {
margin: 0;
}
.toc-item--level-2 {
padding-left: 0;
}
.toc-item--level-3 {
padding-left: 1rem;
}
.toc-item--level-4 {
padding-left: 2rem;
}
.toc-link {
display: block;
padding: 0.5rem 0;
color: var(--text-muted);
text-decoration: none;
font-size: 0.875rem;
line-height: 1.4;
border-left: 2px solid transparent;
padding-left: 0.75rem;
transition: all 0.2s ease;
}
.toc-link:hover {
color: var(--text-primary);
border-left-color: var(--border);
}
.toc-link--active {
color: var(--accent);
border-left-color: var(--accent);
font-weight: 500;
}
/* Плавная анимация при скролле */
html {
scroll-behavior: smooth;
}
/* Компенсация fixed header при переходе по якорю */
:target::before {
content: '';
display: block;
height: 100px;
margin-top: -100px;
}Расширенная версия с нумерацией
class NumberedTableOfContents extends TableOfContents {
renderToc() {
const list = document.createElement('ol');
list.className = 'toc-list toc-list--numbered';
const counters = { 2: 0, 3: 0, 4: 0 };
this.headings.forEach((heading, index) => {
const level = parseInt(heading.tagName[1]);
// Сбрасываем счётчики нижних уровней
for (let l = level + 1; l <= 4; l++) {
counters[l] = 0;
}
counters[level]++;
// Формируем номер (1.2.3)
const number = Object.keys(counters)
.filter(l => l <= level && counters[l] > 0)
.map(l => counters[l])
.join('.');
const item = document.createElement('li');
item.className = `toc-item toc-item--level-${level}`;
item.innerHTML = `
<a href="#${heading.id}" class="toc-link" data-index="${index}">
<span class="toc-number">${number}</span>
<span class="toc-text">${heading.textContent}</span>
</a>
`;
list.appendChild(item);
});
this.tocElement.innerHTML = '<h4 class="toc-title">Содержание</h4>';
this.tocElement.appendChild(list);
this.tocElement.querySelectorAll('.toc-link').forEach(link => {
link.addEventListener('click', (e) => this.handleClick(e));
});
}
}HTML разметка
<article class="article">
<header class="article-header">
<h1>Заголовок статьи</h1>
</header>
<div class="article-layout">
<aside class="article-sidebar">
<nav class="table-of-contents" aria-label="Содержание статьи">
<!-- Генерируется автоматически -->
</nav>
</aside>
<div class="article-content">
<h2>Первый раздел</h2>
<p>Контент...</p>
<h3>Подраздел 1.1</h3>
<p>Контент...</p>
<h2>Второй раздел</h2>
<p>Контент...</p>
</div>
</div>
</article>React-версия
import { useEffect, useState, useRef } from 'react';
interface Heading {
id: string;
text: string;
level: number;
}
interface TableOfContentsProps {
contentRef: React.RefObject<HTMLElement>;
offset?: number;
}
export function TableOfContents({ contentRef, offset = 100 }: TableOfContentsProps) {
const [headings, setHeadings] = useState<Heading[]>([]);
const [activeId, setActiveId] = useState<string>('');
useEffect(() => {
if (!contentRef.current) return;
const elements = contentRef.current.querySelectorAll('h2, h3, h4');
const items: Heading[] = [];
elements.forEach((el, i) => {
if (!el.id) {
el.id = `heading-${i}`;
}
items.push({
id: el.id,
text: el.textContent || '',
level: parseInt(el.tagName[1]),
});
});
setHeadings(items);
}, [contentRef]);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
});
},
{ rootMargin: `-${offset}px 0px -80% 0px` }
);
headings.forEach(({ id }) => {
const el = document.getElementById(id);
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, [headings, offset]);
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
e.preventDefault();
const el = document.getElementById(id);
if (el) {
const top = el.offsetTop - offset;
window.scrollTo({ top, behavior: 'smooth' });
history.pushState(null, '', `#${id}`);
}
};
if (headings.length === 0) return null;
return (
<nav className="table-of-contents" aria-label="Table of contents">
<h4 className="toc-title">Contents</h4>
<ul className="toc-list">
{headings.map(({ id, text, level }) => (
<li key={id} className={`toc-item toc-item--level-${level}`}>
<a
href={`#${id}`}
className={`toc-link ${activeId === id ? 'toc-link--active' : ''}`}
onClick={(e) => handleClick(e, id)}
>
{text}
</a>
</li>
))}
</ul>
</nav>
);
}Итоги
Генерируйте идентификаторы устойчиво: счётчик для дублей, запасной вариант для пустого результата транслитерации. Помните, что якоря — это публичные ссылки, и менять правило их формирования дорого.
Подсветку — через IntersectionObserver. Обработчик scroll с чтением геометрии портит плавность прокрутки на мобильных.
Учитывайте фиксированную шапку при прокрутке к якорю — иначе заголовок раздела окажется под ней. В коде это параметр offset; в CSS ту же задачу решает scroll-margin-top на заголовках, и это более надёжный способ, потому что работает и при переходе по прямой ссылке с якорем, минуя ваш JavaScript.
Не рисуйте оглавление, если заголовков меньше трёх. Содержание из двух пунктов занимает место и не помогает: проверка на количество — одна строка.
Доступность здесь не формальность, а половина смысла компонента. Оглавление — навигация, и она обязана работать с клавиатуры: обернуть в <nav> с aria-label, использовать список и настоящие ссылки <a href="#...">, а не div с обработчиком клика. Отдельно про scroll-behavior: smooth — часть пользователей отключает анимации на уровне системы, и это стоит уважать через @media (prefers-reduced-motion: reduce): для них плавная прокрутка не украшение, а причина головокружения.
Комментарии
Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.