前端技术 · 文章

+ 有更新

Codex同款,从零实现一个磁性短线刻度风格的文章目录

有更新更新于 2026年7月11日

这篇文章在发布后更新过正文内容。

最近在调整博客的文章阅读体验时,我给文章详情页增加了一个新的目录组件。

它平时只显示为一组排列在正文右侧的短线,不会抢占阅读空间。当鼠标移动到目录区域时,距离鼠标最近的刻度会变长,附近的刻度则根据距离逐渐衰减,同时展示对应的章节标题。

为了让交互反馈更明确,我还给刻度切换加入了一个很轻的 Tick 音效。

这篇文章记录这个目录的完整实现过程。

1. 目录效果的灵感来源

其实现在我也不知道应该给这种风格的目录起一个什么名称比较好,这肯定不是我原创的目录风格,为了方便称呼,就暂且先叫它短线目录吧。

第一次见到这个目录风格是在Notion中,他们的文章目录就是一条条短线条组成,然后当前所在目录会用加长线效果进行标识。当时看到的时候就一下爱上你这种简约但很有质感的目录,很符合我一贯的口味,所以那时候在我的Welight以及Ornata两款产品中都实现了类似notion的目录风格。可想而知我是多么的中意它!

但是当前博客中实现的这一个版本和notion风格又有一些本质的区别,主要体现在目录聚焦效果以及鼠标交互的动效方面。

灵感初衷则是来自codex这款桌面app的目录,清爽的质感,平滑的交互动画,很喜欢。

当然了,我在实现这个风格目录的时候,自然也融入了一些自己的想法和细节,所以你可以将它视作notion+codex+我自定义的一个新版本的短线目录。

如果你正在浏览我的博客文章,不妨将视线往内容右侧看看,然后你就可以将鼠标放到目录上面上下移动,可以亲身体验它的视觉和交互效果,音效非常的清脆。

如果你想了解如何实现同款风格的目录,那么,这篇文章就是为你准备的,阅读愉快 !


2. 目录标题的提取和判定

每个人的目录用途和场景都可能不一样,所以这一部分的内容主要是基于为的博客实现原来讲的,你可以选择跳过。当然,如果你也有喜欢我的博客风格,那么可以看看这里,它是一个开源项目: https://github.com/08820048/XuYi ,简单配置一下,开箱即用。

文章正文最终会以 HTML 的形式渲染。

目录组件并不知道文章有哪些章节,所以第一步是找到正文容器,然后提取其中的标题元素。

const container = document.getElementById(containerId)

const headingElements = Array.from(
  container.querySelectorAll<HTMLHeadingElement>('h1, h2, h3'),
).filter((heading) => heading.textContent?.trim())

这里只提取 h1、h2 和 h3。

没有继续提取 h4、h5,主要有两个原因:

  1. 目录层级太深时,导航结构会变得非常碎。

  2. 这个组件的主要作用是帮助读者判断文章位置,而不是完整展示文档结构。

对于普通博客文章来说,三级标题基本已经足够。


2.1 为标题生成锚点

点击目录之后,浏览器需要知道应该滚动到哪里。

因此,每个标题都必须有一个唯一的 id:

function slugifyHeading(text: string, index: number) {
  const base = text
    .trim()
    .toLowerCase()
    .replace(/[^\p{Letter}\p{Number}]+/gu, '-')
    .replace(/^-+|-+$/g, '')
    .slice(0, 48)

  return `article-heading-${index + 1}${base ? `-${base}` : ''}`
}

生成结果大致如下:

1. 整体架构一览
↓
article-heading-1-1-整体架构一览

这里没有只使用标题文本,而是在前面加上标题序号。

原因是不同章节可能使用相同标题,例如:

注意事项
实现细节
注意事项

如果两个元素拥有相同的 id,点击目录时浏览器只能定位到第一个。

因此还需要进行一次重复检测:

const seenIds = new Map<string, number>()

const nextHeadings = headingElements.map((heading, index) => {
  const text = heading.textContent?.trim() || ''
  let id = heading.id.trim() || slugifyHeading(text, index)

  const seenCount = seenIds.get(id) || 0
  seenIds.set(id, seenCount + 1)

  if (seenCount > 0) {
    id = `${id}-${seenCount + 1}`
  }

  heading.id = id

  return {
    id,
    text,
    level: getHeadingLevel(heading.tagName),
  }
})

这样即使标题重复,也能保证最终锚点唯一。

2.2 判断当前阅读章节

目录除了提供跳转,还需要告诉读者目前读到了哪里。

这里使用 IntersectionObserver 监听标题是否进入阅读区域:

const observer = new IntersectionObserver(
  (entries) => {
    for (const entry of entries) {
      if (entry.isIntersecting) {
        visibleHeadings.set(
          entry.target.id,
          entry.boundingClientRect.top,
        )
      } else {
        visibleHeadings.delete(entry.target.id)
      }
    }

    if (visibleHeadings.size > 0) {
      const nextActive = Array
        .from(visibleHeadings.entries())
        .sort((a, b) => a[1] - b[1])[0]?.[0]

      if (nextActive) {
        setActiveId(nextActive)
      }
    }
  },
  {
    rootMargin: '-96px 0px -70% 0px',
    threshold: [0, 1],
  },
)

rootMargin 把真正参与判断的区域限制在页面上方的一小段范围内。

这样只有接近阅读位置的标题才会成为当前章节,而不是标题刚进入屏幕底部就立即切换。

如果当前没有标题处于观察区域,就寻找已经滚动经过的最后一个标题:

const passedHeading = headingElements
  .filter((heading) => heading.getBoundingClientRect().top < 120)
  .at(-1)

if (passedHeading?.id) {
  setActiveId(passedHeading.id)
}

最终效果是:

  • 正常状态下,当前章节刻度线显示为深色。

  • 当前章节刻度仍然保持默认短线长度。

  • 它不会和鼠标悬停动画互相冲突。

3. 短线目录的实现思路

传统目录通常直接显示文字:

1. 整体架构
2. 状态管理
3. 路由设计
4. 总结

这个目录在默认状态下只显示短线:

—
—
—
—
—

对应的 DOM 结构非常简单:

<a
  href={`#${heading.id}`}
  className="article-toc__link"
>
  <span className="article-toc__rule" />
  <span className="article-toc__tooltip">
    {heading.text}
  </span>
</a>

短线本身只是一个普通的 span:

.article-toc__rule {
  display: block;
  width: 0.38rem;
  height: 1.5px;
  border-radius: 999px;
  background: currentColor;
  opacity: 0.36;
}

所有标题在默认状态下使用相同长度。

标题层级不会影响短线长度,因为这个组件希望保持一种安静、统一的视觉节奏。

3.1 当前章节只改变颜色

当前阅读章节不需要变长,只需要变深,这一点在前面堵也提过。

.article-toc__nav:not(:hover)
.article-toc__link[data-active='true']
.article-toc__rule {
  background: var(--editor-ink);
  opacity: 1;
}

这里增加了 :not(:hover)。

它表示只有当用户没有操作目录时,才显示当前阅读章节状态。

当鼠标进入目录后,视觉重点会交给鼠标所在位置,避免同时出现两个高亮中心。


4. 鼠标移动时的磁性刻度导航

这个术语是我自创的,但也许其实有它专属的称呼,但是我懒得去查阅资料你,一个名称而已,这里暂不纠结。

它的核心不是普通的 hover,而是计算每一个目录项和鼠标所在目录项之间的距离。

4.1 记录鼠标所在条目

首先记录当前悬停条目的索引:

const [hoveredIndex, setHoveredIndex] =
  useState<number | null>(null)

每个目录项监听鼠标进入:

<a
  onMouseEnter={() => setHoveredIndex(index)}
  onFocus={() => setHoveredIndex(index)}
>

鼠标离开整个目录区域后清空:

<nav onMouseLeave={() => setHoveredIndex(null)}>

这里把 onMouseLeave 放在整个目录容器上,而不是每一个链接上。

否则鼠标从一条刻度移动到下一条时,中间会产生一次不必要的闪烁。

放在父容器上之后,鼠标只要还在目录区域内,邻近效果就会一直存在。

4.2 计算目录距离

对于每一个标题,计算它和鼠标中心之间的索引距离:

const proximity =
  hoveredIndex === null
    ? null
    : Math.abs(index - hoveredIndex)

例如鼠标位于索引 10:

索引       距离
7           3
8           2
9           1
10          0
11          1
12          2
13          3

然后把距离写入 DOM:

data-proximity={
  proximity !== null && proximity <= 3
    ? proximity
    : undefined
}

最后通过 CSS 控制不同距离的线宽:

/* 默认长度 */
.article-toc__rule {
  width: 0.38rem;
}

/* 上下第三条,已经衰减回默认状态 */
[data-proximity='3'] .article-toc__rule {
  width: 0.38rem;
}

/* 上下第二条 */
[data-proximity='2'] .article-toc__rule {
  width: 0.76rem;
  opacity: 0.52;
}

/* 上下第一条 */
[data-proximity='1'] .article-toc__rule {
  width: 1.18rem;
  opacity: 0.72;
}

/* 鼠标中心 */
[data-proximity='0'] .article-toc__rule {
  width: 1.72rem;
  height: 2px;
  opacity: 1;
}

形成的视觉结构是:

      —
      —
     ——
   ————
——————
   ————
     ——
      —
      —

4.3 为什么使用 CSS Transition

线宽变化没有使用 Keyframes,而是使用 CSS Transition:

.article-toc__rule {
  transition:
    opacity 160ms ease,
    width 180ms cubic-bezier(0.2, 0, 0, 1),
    height 180ms cubic-bezier(0.2, 0, 0, 1);
}

这是因为鼠标移动是随时可能改变方向的。

如果使用固定关键帧动画,用户快速移动鼠标时,旧动画可能还没有执行完,新动画又重新开始,视觉上容易产生卡顿。

CSS Transition 可以随时改变目标值,这种可中断性非常适合磁性导航。

4.4 标题卡片只跟随中心刻度

只有距离为 0 的目录项显示标题:

.article-toc__tooltip {
  opacity: 0;
  scale: 0.98;
  translate: 0.35rem -50%;
}

[data-proximity='0'] .article-toc__tooltip {
  opacity: 1;
  scale: 1;
  translate: 0 -50%;
}

标题卡片使用绝对定位,所以也不会挤压目录或正文:

.article-toc__tooltip {
  position: absolute;
  left: 2.25rem;
  top: 50%;
  width: 14rem;
}

5. 音效效果的实现

视觉反馈完成之后,我又给目录切换增加了一个非常轻的 Tick 音效。

这里使用的是 Cuelume,说来也巧,这是我最近在X上看到的一个项目,Cuelume 和普通音效库有一点不同:它不需要加载 MP3 或 WAV 文件,而是通过 Web Audio API 实时合成声音。我觉得挺有意思的,没想到这么快就派上用场了。

组件挂载时初始化绑定:

import { bind as bindCuelume } from 'cuelume'

useEffect(() => {
  bindCuelume()
}, [])

之后,Cuelume 会监听页面上的:

data-cuelume-hover="tick"

当鼠标进入新刻度时播放 Tick。这就是大致的一个实现记录了。

版权声明

本文内容版权归作者或相关权利人所有。转载、引用或其他使用请遵循相应授权条款,并保留本文链接。

本文链接:https://xuyi.dev/2026-07-11-4rcwfj