本文解决的是 Hugo 博客目录(TOC)层级不够清晰、交互反馈偏弱的问题:在不改 Hugo 目录生成逻辑、也不改 JavaScript 交互逻辑的前提下,只通过 SCSS 调整标记点、引导线、悬停态和容器样式,让长文目录更好读也更好用。
![https://img.philohao.com/blog/2025/09/20250915-01.webp https://img.philohao.com/blog/2025/09/20250915-01.webp]()
适用场景
适合已经启用 Hugo 文章目录、但默认 TOC 在长文中显得层级混乱或视觉过轻的博客。尤其是经常写技术教程、读书笔记、折腾记录这类多级标题文章时,目录样式的可读性会直接影响阅读体验。
如果你的需求是「目录能生成,但看起来不够像一个好用的导航」,这篇就比较对症;如果你想改的是目录锚点生成、滚动监听或移动端抽屉,那就属于更底层的模板和 JS 改造了。
背景
最近在看自己的网站时,觉得文章右侧的目录(Table of Contents)样式有些单调。它虽然能用,但在视觉上还有很大的提升空间。
原来的目录主要依赖简单圆点和缩进来表达层级。短文还好,一旦文章标题多起来,问题就明显了:一级、二级、三级标题之间的区分不够直观;鼠标移上去也没有明确反馈;浮动目录贴在页面边上时,存在感有点尴尬,既不像正文的一部分,也不像一个独立组件。
所以这次改造的目标其实很克制:不推倒重来,只把原有 TOC 打磨得更像一个可读、可点、可维护的小导航。
先看清楚原来的工作方式
动手之前,我先把主题里和 TOC 有关的链路理了一遍。Aether 主题的目录并不是我手写出来的,而是沿用 Hugo 的 .TableOfContents,再由主题模板和 JS 决定它放在哪里、什么时候高亮。
1. Hugo 负责生成目录结构
在文章模板里,静态目录和浮动目录共用同一份 Hugo 输出:
1
| {{- dict "Content" .TableOfContents "Ruby" $params.ruby "Fraction" $params.fraction "Fontawesome" $params.fontawesome | partial "function/content.html" | safeHTML -}}
|
也就是说,目录最核心的 HTML 结构来自 Hugo 本身。它通常会生成类似这样的嵌套列表:
1
2
3
4
5
6
7
8
9
10
11
| <nav id="TableOfContents">
<ul>
<li><a href="#背景">背景</a></li>
<li>
<a href="#核心步骤">核心步骤</a>
<ul>
<li><a href="#增强层级感">增强层级感</a></li>
</ul>
</li>
</ul>
</nav>
|
这个结构有一个好处:天然就是 ul > li > a > ul 的树状结构。换句话说,层级关系其实已经在 HTML 里了,我没有必要为了视觉效果去改模板,只要让 CSS 更好地「读懂」这棵树就行。
2. 模板准备两个容器
主题模板里同时准备了两个目录容器:
#toc-auto:桌面端右侧浮动目录;#toc-static:正文里的静态折叠目录。
浮动目录先给一个空容器:
1
2
3
4
| <div class="toc" id="toc-auto">
<h2 class="toc-title">目录</h2>
<div class="toc-content" id="toc-content-auto"></div>
</div>
|
静态目录则放在正文标题之后、文章内容之前:
1
2
3
4
5
6
| <div class="details toc" id="toc-static">
<div class="details-summary toc-title">...</div>
<div class="details-content toc-content" id="toc-content-static">
{{ .TableOfContents }}
</div>
</div>
|
这里的设计挺巧妙:页面上只有一份真正的 #TableOfContents,但它可以被放进不同容器。也正因为如此,我这次改样式时尽量把通用规则写在 .toc 下,再针对 #toc-auto 和 #toc-static 分别补外观。
3. JavaScript 负责搬运和高亮
主题的 initToc() 会判断当前该使用静态目录还是浮动目录,然后把 #TableOfContents 移到对应容器中。如果使用浮动目录,它还会做三件事:
- 根据文章区域位置计算
#toc-auto 的 left 和 maxWidth; - 根据滚动位置在
absolute 和 fixed 之间切换; - 给当前标题对应的目录链接加上
.active,同时给父级 li 加上 .has-active。
这就解释了为什么我不想碰 JS:滚动定位、当前阅读位置、高亮父级这些逻辑本来就是通的。我要解决的是「这些状态出现以后,视觉上怎么表达得更清楚」。
设计原则
理清底层以后,这次改造就不是简单地「加点好看的 CSS」,而是围绕几个限制来做取舍。
1. 不改目录语义
目录本质上还是一个导航列表,所以我保留 Hugo 生成的 nav / ul / li / a 结构,不额外包裹 span,也不在模板里塞装饰字符。层级标记全部交给 ::before 伪元素,这样 HTML 仍然干净,后续换主题或排查锚点问题也更安心。
2. 通用样式优先,容器样式分开
.toc 里只放「目录作为列表」的通用规则,例如字号、列表重置、链接布局、层级标记、引导线、hover 状态。
#toc-auto 和 #toc-static 则只负责各自的容器气质:浮动目录更像贴在页面右侧的轻量导航卡片;静态目录更像正文中的折叠信息块。这样拆开后,哪怕以后移动端或桌面端显示策略变了,列表本身的层级表达也不用重写。
3. 尽量接入主题变量
颜色没有写死成某一个蓝色或灰色,而是优先使用主题已有变量,比如 $single-link-hover-color、$global-border-color、$code-background-color。这样浅色、深色、黑色三套主题能一起适配,不会出现浅色主题好看、深色主题突然刺眼的问题。
当然,这也带来一个小取舍:这份 SCSS 不是完全独立的组件,迁移到别的主题时,需要先把这些变量替换成目标主题自己的颜色体系。
核心改造
1. 用伪元素表达层级,而不是依赖默认圆点
原来的目录层级主要靠浏览器默认列表样式和缩进。这个方案能用,但视觉信息太弱。我的改法是先把默认列表样式拿掉:
1
2
3
4
5
6
7
8
| .toc {
.toc-content {
ul {
padding-left: 0;
list-style: none;
}
}
}
|
然后把每个链接变成可定位的块级元素:
1
2
3
4
5
6
| .toc .toc-content ul a {
display: block;
position: relative;
padding-left: 1.35rem;
text-decoration: none;
}
|
这里的 padding-left: 1.35rem 很关键,它不是单纯缩进文字,而是给左侧的伪元素预留一个稳定位置。后面无论是实心圆、空心圆,还是高亮状态,都可以画在这块区域里,文字本身不会跟着晃。
层级标记则用直接子代选择器区分:
1
2
3
4
5
6
7
8
9
10
11
| .toc .toc-content ul > li > a::before {
content: "●";
}
.toc .toc-content ul ul > li > a::before {
content: "○";
}
.toc .toc-content ul ul ul > li > a::before {
content: "◌";
}
|
这里我选择 ● / ○ / ◌,不是为了花哨,而是因为它们在小字号下仍然比较容易分辨:一级标题更重,二级标题更轻,三级标题再往后退一点。读者扫一眼目录,就能大概知道文章骨架。
2. 用引导线把子标题「收」到父标题下面
只有不同圆点还不够,长文里多个二级、三级标题连续出现时,眼睛还是容易飘。所以我给子级 ul 增加了一条细引导线:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| .toc .toc-content ul ul {
padding-left: 1rem;
position: relative;
}
.toc .toc-content ul ul::before {
content: '';
position: absolute;
left: 0.35rem;
top: 0.5rem;
bottom: 0.5rem;
width: 1px;
background-color: $global-border-color;
}
|
这条线的作用不是装饰,而是帮助眼睛理解「这一组小标题属于上面的父标题」。尤其是浮动目录宽度有限时,文字会换行,如果没有这条线,层级关系就更容易散掉。
top 和 bottom 没有写成 0,是为了让线条不要顶到整组列表的边缘,视觉上会轻一点,也不会像表格边框一样生硬。
3. hover 反馈要明显,但不能抢正文
目录是导航,鼠标移上去应该有反馈。我的处理是文字变成链接 hover 色,同时给一个很淡的背景:
1
2
3
4
5
| .toc .toc-content ul a:hover {
color: $single-link-hover-color;
background-color: rgba($global-border-color, 0.5);
border-radius: 4px;
}
|
这个背景色故意取得很轻,因为目录始终是辅助组件,不能比正文内容更抢眼。真正需要强调的是「这里可以点击」,而不是做成按钮一样的强交互。
深色和黑色主题则单独降低透明度:
1
2
3
4
| [theme=dark] & {
color: $single-link-hover-color-dark;
background-color: rgba($global-border-color-dark, 0.2);
}
|
深色模式下如果透明度过高,hover 背景会显得一块一块的,反而不耐看。这里用 0.2,基本就是给用户一个轻提示。
4. active 状态复用 hover 色
浮动目录有滚动高亮逻辑,JS 会给当前目录链接加 .active。所以样式上我让 active 链接加粗,并复用 hover 色:
1
2
3
4
5
6
7
8
| #toc-auto .toc-content ul a.active {
font-weight: bold;
color: $single-link-hover-color;
}
#toc-auto .toc-content ul a.active::before {
color: $single-link-hover-color;
}
|
这里有一个小细节:不仅文字要变色,前面的圆点也要一起变色。否则当前标题虽然高亮了,但标记点还停留在默认颜色,会有一点割裂。
同时,主题 JS 会给 active 链接的父级加 .has-active。原样式里子级目录默认折叠,我保留了这个逻辑:
1
2
3
4
5
6
7
| #toc-auto .toc-content ul ul {
display: none;
}
#toc-auto .toc-content ul .has-active > ul {
display: block;
}
|
这样浮动目录不会一次性把所有层级都摊开,而是跟着当前阅读位置展开相关分支。对长文来说,这比完整展开更清爽。
5. 浮动目录做成轻量卡片
浮动目录原本更像一列贴在旁边的文字。我希望它更像一个「独立但不突兀」的导航卡片,所以加了内边距、边框、圆角和背景模糊:
1
2
3
4
5
6
7
| #toc-auto {
padding: 0 1rem;
border: 1px solid #b1b1b1;
border-left: 3px solid $global-border-color;
border-radius: 0 8px 8px 0;
@include blur;
}
|
这里左边框比其他边更粗,是为了保留一点「侧边导航」的感觉;右侧圆角则让它不要像浏览器原生边框那么硬。@include blur 继续沿用主题已有的毛玻璃效果,和站内其他组件保持一致。
不过它仍然由 JS 控制位置:初始 visibility: hidden,等 initToc() 算好文章右侧位置后再显示。这样可以避免页面刚加载时目录闪一下。
6. 静态目录融入正文
静态目录在正文里出现时,读者会把它当成文章内容的一部分,所以我没有给它做太强的浮层感,而是改成边框卡片:
1
2
3
4
5
6
| #toc-static {
margin: 1.5rem 0;
border: 1px solid $global-border-color;
border-radius: 8px;
overflow: hidden;
}
|
标题栏继续使用代码块背景色:
1
2
3
4
5
6
7
8
| #toc-static .toc-title {
display: flex;
justify-content: space-between;
line-height: 2em;
padding: 0.5rem 1rem;
background: $code-background-color;
cursor: pointer;
}
|
cursor: pointer 是一个很小但很重要的提示,因为静态目录本质上是可折叠的 details 组件。用户看到鼠标样式变化,就会知道标题栏不是普通文本。
7. 行间距最后再收紧
第一版改完后,整体效果已经出来了,但目录看起来还有点松散。问题主要来自链接的上下 padding:我一开始为了扩大点击区域,给了 2px 0,结果目录项一多就显得臃肿。
最后我把它收成:
1
2
| padding: 0 0;
padding-left: 1.35rem;
|
注意这里不是把点击体验完全牺牲掉,而是因为目录本身有行高,链接又是 display: block,实际可点区域仍然够用。对于右侧浮动目录这种窄组件来说,紧凑一点反而更适合。
最终效果
改造后的目录主要解决了三个问题:
- 层级更清楚:不同标记点加上子级引导线,标题树更容易扫读;
- 状态更明确:hover 和 active 都有颜色反馈,当前阅读位置更好判断;
- 容器更完整:浮动目录像侧边导航卡片,静态目录像正文信息块,两者都不再只是散落的文字列表。
这次我比较满意的地方在于,它没有改变 Hugo 的目录生成方式,也没有动主题原有的滚动监听。目录仍然是那份目录,只是 CSS 更准确地把它的结构和状态表达出来了。
最终代码展示
下面是这次改造后的核心 SCSS,主要对应主题里的 themes/aether/assets/css/_partial/_single/_toc.scss:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
| // --- 变量定义 (假设这些变量已在别处定义) ---
// $toc-title-font-size: 1.1em;
// $toc-content-font-size: 0.9em;
// $single-link-color: #333;
// $single-link-hover-color: #007bff;
// $global-border-color: #eee;
// ... 以及对应的 dark 和 black 主题变量
// --- 通用 TOC 基础样式 ---
.toc {
.toc-title {
font-size: $toc-title-font-size;
font-weight: bold;
text-transform: uppercase;
}
.toc-content {
font-size: $toc-content-font-size;
// --- 移除 text-indent,改用伪元素和 padding-left 控制层级 ---
ul {
padding-left: 0;
list-style: none;
a {
display: block;
padding: 0 0;
position: relative;
padding-left: 1.35rem;
text-decoration: none;
transition: color 0.2s, background-color 0.2s;
&:hover {
color: $single-link-hover-color;
background-color: rgba($global-border-color, 0.5);
border-radius: 4px;
[theme=dark] & {
color: $single-link-hover-color-dark;
background-color: rgba($global-border-color-dark, 0.2);
}
[theme=black] & {
color: $single-link-hover-color-black;
background-color: rgba($global-border-color-black, 0.2);
}
}
}
> li > a::before {
content: "●";
font-weight: bolder;
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
color: $single-link-color;
[theme=dark] & { color: $single-link-color-dark; }
[theme=black] & { color: $single-link-color-black; }
}
ul {
padding-left: 1rem;
position: relative;
&::before {
content: '';
position: absolute;
left: 0.35rem;
top: 0.5rem;
bottom: 0.5rem;
width: 1px;
background-color: $global-border-color;
[theme=dark] & { background-color: $global-border-color-dark; }
[theme=black] & { background-color: $global-border-color-black; }
}
> li > a::before {
content: "○";
}
ul > li > a::before {
content: "◌";
}
}
}
}
ruby {
background: $code-background-color;
rt {
color: $global-font-secondary-color;
}
[theme=dark] & {
background: $code-background-color-dark;
rt {
color: $global-font-secondary-color-dark;
}
}
[theme=black] & {
background: $code-background-color-black;
rt {
color: $global-font-secondary-color-black;
}
}
}
}
// --- 浮动目录 ---
#toc-auto {
display: block;
position: absolute;
width: $MAX_LENGTH;
max-width: 0;
padding: 0 1rem;
border: 1px solid #b1b1b1;
border-left: 3px solid $global-border-color;
@include overflow-wrap(break-word);
box-sizing: border-box;
top: 10rem;
left: 0;
visibility: hidden;
border-radius: 0 8px 8px 0;
@include blur;
[theme=dark] & {
border-left-color: $global-border-color-dark;
}
[theme=black] & {
border-left-color: $global-border-color-black;
}
[header-desktop=normal] & {
top: 5rem;
}
.toc-title {
margin: 1rem 0;
}
.toc-content {
ul {
a.active {
font-weight: bold;
color: $single-link-hover-color;
[theme=dark] & { color: $single-link-hover-color-dark; }
[theme=black] & { color: $single-link-hover-color-black; }
&::before {
color: $single-link-hover-color;
[theme=dark] & { color: $single-link-hover-color-dark; }
[theme=black] & { color: $single-link-hover-color-black; }
}
}
.has-active > ul {
display: block;
}
ul {
display: none;
}
}
&.always-active ul {
display: block;
}
> nav > ul {
margin: .625rem 0;
}
}
}
// --- 静态目录(页面内) ---
#toc-static {
display: none;
margin: 1.5rem 0;
border: 1px solid $global-border-color;
border-radius: 8px;
overflow: hidden;
[theme=dark] & { border-color: $global-border-color-dark; }
[theme=black] & { border-color: $global-border-color-black; }
&[kept=true] {
display: block;
}
.toc-title {
display: flex;
justify-content: space-between;
line-height: 2em;
padding: 0.5rem 1rem;
background: $code-background-color;
cursor: pointer;
[theme=dark] & { background: $code-background-color-dark; }
[theme=black] & { background: $code-background-color-black; }
}
.toc-content {
background-color: transparent;
> nav > ul {
margin: 0;
padding: .8rem 1rem .8rem 1rem;
}
}
}
|
小结
这次 TOC 改造看起来只是改样式,但真正重要的是先弄清楚边界:Hugo 负责生成目录结构,模板负责提供静态和浮动两个容器,JavaScript 负责搬运目录与滚动高亮,而 SCSS 负责把层级、状态和容器气质表达清楚。
最终方案的优点是风险比较低:不影响目录锚点,不影响滚动定位,也不需要维护额外脚本;缺点是样式和 Aether 主题变量绑定较深,迁移到其他主题时需要重新适配。
后续如果继续优化,我可能会再看看移动端折叠目录、当前阅读位置的过渡动画,以及多级标题过深时是否需要限制显示层级。但当前这个版本已经足够解决一个很实际的问题:长文目录终于不再只是「能用」,而是开始「好读」了。
参考资料
(2025-09-15@深圳)