Реализация Table of Contents для длинных статей на сайте

Наша компания занимается разработкой, поддержкой и обслуживанием сайтов любой сложности. От простых одностраничных сайтов до масштабных кластерных систем построенных на микро сервисах. Опыт разработчиков подтвержден сертификатами от вендоров.

Разработка и обслуживание любых видов сайтов:

Информационные сайты или веб-приложения
Сайты визитки, landing page, корпоративные сайты, онлайн каталоги, квиз, промо-сайты, блоги, новостные ресурсы, информационные порталы, форумы, агрегаторы
Сайты или веб-приложения электронной коммерции
Интернет-магазины, B2B-порталы, маркетплейсы, онлайн-обменники, кэшбэк-сайты, биржи, дропшиппинг-платформы, парсеры товаров
Веб-приложения для управления бизнес-процессами
CRM-системы, ERP-системы, корпоративные порталы, системы управления производством, парсеры информации
Сайты или веб-приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, конструкторы сайтов, порталы предоставления электронных услуг, видеохостинги, тематические порталы

Это лишь некоторые из технических типов сайтов, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента

Предлагаемые услуги
Показано 1 из 1 услугВсе 2065 услуг
Реализация Table of Contents для длинных статей на сайте
Простая
от 1 рабочего дня до 3 рабочих дней
Часто задаваемые вопросы

Наши компетенции:

Этапы разработки

Последние работы

  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    874
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Разработка веб-сайта для компании ФИКСПЕР
    851

Реализация 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 дня.