惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

The GitHub Blog
The GitHub Blog
Hugging Face - Blog
Hugging Face - Blog
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
WordPress大学
WordPress大学
Y
Y Combinator Blog
Google DeepMind News
Google DeepMind News
大猫的无限游戏
大猫的无限游戏
Vercel News
Vercel News
V
Visual Studio Blog
Microsoft Azure Blog
Microsoft Azure Blog
MyScale Blog
MyScale Blog
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
罗磊的独立博客
N
Netflix TechBlog - Medium
M
MIT News - Artificial intelligence
G
Google Developers Blog
阮一峰的网络日志
阮一峰的网络日志
P
Proofpoint News Feed
小众软件
小众软件
Engineering at Meta
Engineering at Meta
D
DataBreaches.Net
S
SegmentFault 最新的问题
H
Help Net Security
量子位

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
плавающее оглавление на solidjs
собачья будк · 2026-04-24 · via DEV Community

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

в этой статье — разбор 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)
}

Enter fullscreen mode Exit fullscreen mode

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


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

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

const debouncedUpdateHeadings = debounce(500, updateHeadings)

Enter fullscreen mode Exit fullscreen mode

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


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

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

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

  return rect.top <= DEFAULT_HEADER_OFFSET + 24
}

Enter fullscreen mode Exit fullscreen mode

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

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

  setActiveHeaderIndex(index)
})

Enter fullscreen mode Exit fullscreen mode


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

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

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

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

Enter fullscreen mode Exit fullscreen mode


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

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

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)

Enter fullscreen mode Exit fullscreen mode


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

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

onMount(() => {
  setIsDocumentReady(true)

  debouncedUpdateHeadings()

  window.addEventListener('scroll', updateActiveHeader)

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

Enter fullscreen mode Exit fullscreen mode


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

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

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

Enter fullscreen mode Exit fullscreen mode


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

основной 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>

Enter fullscreen mode Exit fullscreen mode


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

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

.TableOfContentsHeadingsItemH3 {
  padding-left: 8px;
}

.TableOfContentsHeadingsItemH4 {
  padding-left: 16px;
}

Enter fullscreen mode Exit fullscreen mode


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

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

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

Enter fullscreen mode Exit fullscreen mode


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

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

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

Enter fullscreen mode Exit fullscreen mode


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;
  }
}

Enter fullscreen mode Exit fullscreen mode


toggle видимости

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

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

Enter fullscreen mode Exit fullscreen mode


итог

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

это:

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

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

  • scroll tracking с throttle

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

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

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

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

source code