Оглавление для длинной статьи — та задача, где готовая библиотека обычно избыточна, а своя реализация занимает сотню строк. Вопрос только в том, какие сто строк: наивная версия ломается на кириллице, на повторяющихся заголовках и на подсветке активного пункта.
Разберём реализацию, где эти три случая обработаны, и почему подсветка сделана через IntersectionObserver, а не через обработчик прокрутки.
Что должно уметь автоматическое оглавление
Автоматически генерировать оглавление статьи на основе заголовков. При клике — плавный скролл к нужному разделу, с подсветкой текущего пункта.
Как сгенерировать оглавление на JavaScript
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): для них плавная прокрутка не украшение, а причина головокружения.
Частые вопросы
Как сгенерировать оглавление статьи на JavaScript?
Собрать заголовки нужных уровней через querySelectorAll, присвоить каждому устойчивый идентификатор, построить список ссылок с якорями и повесить подсветку активного пункта. Не рисуйте оглавление, если заголовков меньше трёх: содержание из двух пунктов занимает место и не помогает, а проверка на количество — одна строка.
Почему две ссылки в оглавлении ведут в одно место?
Из-за повторяющихся заголовков. Раздел «Итоги» в двух местах статьи даст два одинаковых идентификатора, и обе ссылки уведут к первому. Решается счётчиком: при обнаружении дубля к идентификатору добавляется числовой суффикс.
Почему у русского заголовка получается пустой якорь?
Потому что транслитерация может вернуть пустую строку, если заголовок состоял из символов, которые правило не покрывает, — и вы получите пустой идентификатор, то есть неработающую ссылку. Запасной вариант вида heading плюс порядковый номер при пустом результате обязателен.
IntersectionObserver или обработчик scroll для подсветки?
IntersectionObserver. Классическая реализация на scroll перебирает заголовки и считает их позиции на каждом событии, а событие прокрутки срабатывает десятки раз в секунду, и чтение геометрии внутри обработчика заставляет браузер пересчитывать раскладку. На мобильных это заметно как подтормаживающая прокрутка — то есть ровно там, где плавность важнее всего. IntersectionObserver решает ту же задачу без опроса и без перерасчёта раскладки в вашем коде и не требует ручного ограничения частоты вызовов.
Как учесть фиксированную шапку при переходе к якорю?
Надёжнее всего через CSS-свойство scroll-margin-top на заголовках, а не смещением в JavaScript. Причина в том, что CSS работает и при переходе по прямой ссылке с якорем, минуя ваш скрипт, а параметр смещения в коде — только при клике внутри оглавления.
Что учесть в оглавлении для доступности?
Оглавление — это навигация, и она обязана работать с клавиатуры: обернуть в элемент nav с атрибутом aria-label, использовать список и настоящие ссылки, а не div с обработчиком клика. И уважайте системную настройку уменьшения анимаций: для части пользователей плавная прокрутка не украшение, а причина головокружения, поэтому scroll-behavior стоит отключать в медиазапросе prefers-reduced-motion.