Technology Apr 24, 2026 · 2 min read

плавающее оглавление на solidjs

иногда плавающее оглавление кажется довольно простой штукой: берёшь заголовки, рисуешь список, добавляешь якоря — готово. в этой статье — разбор table of contents, который я собрал на solidjs: от поиска заголовков до sticky/fixed поведения и адаптивного ui. контекст задача : собрат...

DE
DEV Community
by собачья будка
плавающее оглавление на solidjs

иногда плавающее оглавление кажется довольно простой штукой: берёшь заголовки, рисуешь список, добавляешь якоря — готово.

в этой статье — разбор table of contents, который я собрал на solidjs: от поиска заголовков до sticky/fixed поведения и адаптивного ui.

контекст

задача :

  • собрать список заголовков статьи

  • сделать быстрые переходы по клику

  • подсвечивать текущий раздел при скролле

  • поддерживать мобильный и десктопный режим

  • уметь скрываться и появляться

сбор заголовков из документа

вся система начинается с обхода dom и поиска заголовков внутри контейнера статьи.

const updateHeadings = () => {
  const parent = document.querySelector(props.parentSelector)

  if (!parent) return

  const nodes = Array.from(
    parent.querySelectorAll<HTMLElement>('h1, h2, h3, h4')
  )

  setHeadings(nodes)
  setAreHeadingsLoaded(true)
}

по сути это “снимок структуры документа”.

debounce пересборки оглавления

чтобы не пересобирать список слишком часто:

const debouncedUpdateHeadings = debounce(500, updateHeadings)

это особенно важно, если контент может динамически изменяться.

определение активного заголовка

самая “живая” часть компонента — отслеживание скролла.

const isInViewport = (el: HTMLElement) => {
  const rect = el.getBoundingClientRect()

  return rect.top <= DEFAULT_HEADER_OFFSET + 24
}

и дальше поиск активного элемента:

const updateActiveHeader = throttle(50, () => {
  const index = headings().findIndex((h) => isInViewport(h))

  setActiveHeaderIndex(index)
})

переход к заголовку

при клике происходит ручной scroll с компенсацией fixed header’а:

const scrollToHeader = (element: HTMLElement) => {
  const top =
    element.getBoundingClientRect().top -
    document.body.getBoundingClientRect().top -
    DEFAULT_HEADER_OFFSET

  window.scrollTo({
    top,
    behavior: 'smooth'
  })
}

состояние компонента

оглавление держит сразу несколько слоёв состояния:

const [headings, setHeadings] = createSignal<HTMLElement[]>([])
const [activeHeaderIndex, setActiveHeaderIndex] = createSignal(-1)
const [isVisible, setIsVisible] = createSignal(true)
const [areHeadingsLoaded, setAreHeadingsLoaded] = createSignal(false)
const [isDocumentReady, setIsDocumentReady] = createSignal(false)

подписка на скролл

основная реактивность строится через window scroll:

onMount(() => {
  setIsDocumentReady(true)

  debouncedUpdateHeadings()

  window.addEventListener('scroll', updateActiveHeader)

  onCleanup(() => {
    window.removeEventListener('scroll', updateActiveHeader)
  })
})

реакция на изменение статьи

если меняется тело статьи — пересобираем структуру:

createEffect(
  on(
    () => props.body,
    () => {
      if (isDocumentReady()) {
        debouncedUpdateHeadings()
      }
    }
  )
)

рендер списка

основной ui — это список кнопок, привязанных к заголовкам.

<ul class={styles.TableOfContentsHeadingsList}>
  <For each={headings()}>
    {(h, index) => (
      <li>
        <button
          class={clsx(styles.TableOfContentsHeadingsItem, {
            [styles.TableOfContentsHeadingsItemH3]: h.nodeName === 'H3',
            [styles.TableOfContentsHeadingsItemH4]: h.nodeName === 'H4',
            [styles.active]: index() === activeHeaderIndex()
          })}
          innerHTML={h.textContent || ''}
          onClick={(e) => {
            e.preventDefault()
            scrollToHeader(h)
          }}
        />
      </li>
    )}
  </For>
</ul>

визуальная иерархия заголовков

уровни заголовков просто сдвигаются визуально:

.TableOfContentsHeadingsItemH3 {
  padding-left: 8px;
}

.TableOfContentsHeadingsItemH4 {
  padding-left: 16px;
}

активный пункт

подсветка текущего раздела минимальная, но важная:

.TableOfContentsHeadingsItem.active {
  font-weight: 700 !important;
}

sticky поведение на десктопе

на больших экранах оглавление становится sticky-блоком:

.TableOfContentsContainer {
  @include media-breakpoint-up(xl) {
    position: sticky;
    top: 100px;
    height: calc(100vh - 120px);
    flex-direction: column;
  }
}

mobile режим (fixed bottom panel)

на мобильных это уже не sidebar, а выезжающая панель:

.TableOfContentsFixedWrapper {
  @include media-breakpoint-down(xl) {
    position: fixed;
    bottom: 0;
    left: 0;
    width: 100%;
    max-height: 50vh;
    background: #000;
    color: #fff;
  }
}

toggle видимости

оглавление можно скрывать и показывать:

const toggleIsVisible = () => {
  setIsVisible((v) => !v)
}

итог

в итоге это не просто оглавление.

это:

  • парсинг DOM структуры статьи

  • debounce пересборки контента

  • scroll tracking с throttle

  • вычисление активного раздела

  • sticky + fixed адаптивный layout

  • UI, который живёт вместе со скроллом

и самое интересное — такие компоненты всегда выглядят простыми в начале, но постепенно превращаются в полноценный слой навигации поверх документа, который “понимает” структуру текста и помогает по нему двигаться.

source code

DE
Source

This article was originally published by DEV Community and written by собачья будка.

Read original article on DEV Community
Back to Discover

Reading List