前端技术 · 文章
+ 有更新Codex同款,从零实现一个磁性短线刻度风格的文章目录
这篇文章在发布后更新过正文内容。
最近在调整博客的文章阅读体验时,我给文章详情页增加了一个新的目录组件。
它平时只显示为一组排列在正文右侧的短线,不会抢占阅读空间。当鼠标移动到目录区域时,距离鼠标最近的刻度会变长,附近的刻度则根据距离逐渐衰减,同时展示对应的章节标题。
为了让交互反馈更明确,我还给刻度切换加入了一个很轻的 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,主要有两个原因:
目录层级太深时,导航结构会变得非常碎。
这个组件的主要作用是帮助读者判断文章位置,而不是完整展示文档结构。
对于普通博客文章来说,三级标题基本已经足够。
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。这就是大致的一个实现记录了。
版权声明
本文内容版权归作者或相关权利人所有。转载、引用或其他使用请遵循相应授权条款,并保留本文链接。